一、这玩意儿是啥

长视频应用的核心功能是沉浸式的视频播放和互动,包括首页推荐、视频搜索、视频详情、视频评论、全屏播放等。

开发这类应用,经常遇到这些痛难点问题:

平级工程目录逻辑混乱,依赖关系不清晰。

应用窗口宽度改变时图片变形。

图片等比改变大小导致容器组件高度实时变化。

首页沉浸式设计页面的背景图与透视效果难以实现。

获取不到特定组件在应用窗口内的坐标。

如何适配多设备下自定义滑动效果。

折叠屏悬停态页面开发无从下手。

本文从UX设计、工程管理、页面开发、功能开发四个角度,介绍长视频应用在多设备开发中的最佳实践。

二、工程咋管理

开发"一多"应用时,会面临工程结构目录的划分问题。考虑到复用性和可维护性,推荐使用三层架构。

HarmonyOS的分层架构包括三个层次:

产品定制层:products,针对不同产品形态的定制。

基础特性层:features,独立的功能模块。

公共能力层:commons,公共的工具和常量。

长视频应用根据三层架构划分目录:

四个页面功能不同,互不依赖,根据页面划分为四个features:首页-home、视频搜索页-search、视频详情页-videoDetail、全屏播放页-videoPlayer。

公共常量、媒体播放工具、窗口管理工具等划分为commons:基础能力-base。

工程结构如下:

├──commons                                    // 公共能力层
│  ├──base/src/main/ets                       // 基础能力
│  │  ├──constants
│  │  └──utils
│  └──base/Index.ets                          // 对外接口类
├──features                                   // 基础特性层
│  ├──home/src/main/ets                       // 首页
│  │  ├──constants
│  │  ├──utils
│  │  ├──view
│  │  └──viewmodel
│  ├──home/src/main/resources                 // 资源文件目录
│  ├──home/Index.ets                          // 对外接口类
│  ├──search/src/main/ets                     // 搜索页
│  ├──videoDetail/src/main/ets                // 视频详情页
│  ├──videoPlayer/src/main/ets                // 全屏播放页
└──products                                   // 产品定制层
   └──phone/src/main/ets                      // 支持手机、折叠屏、平板、PC/2in1
   │  ├──entryability
   │  └──pages
   └──phone/src/main/resources                // 资源文件目录

这样划分的好处是:每个页面模块独立,互不影响;公共能力复用,减少重复代码;产品定制层统一管理入口。

三、首页咋开发

首页主要推荐精选视频,满足用户观看需求。

观察首页的UX设计图,可以将页面划分为8个区域:

区域1:底部/侧边页签。

区域2:顶部页签及搜索框。

区域3:Banner图。

区域4:图标列表。

区域5:推荐视频。

区域6:新片发布。

区域7:每日佳片。

区域8:往期回顾。

整个页面响应式适配,借助栅格组件能力监听不同断点变化实现不同的布局效果。

底部/侧边页签

底部/侧边页签区域,使用Tabs组件,设置在不同断点下的vertical属性,实现显示在首页的不同位置。

在sm和md断点下,页签显示在底部,高度为56vp;在lg断点下页签显示在左侧,宽度为96vp,且页签居中显示。

代码实现:

Tabs({ barPosition: this.currentWidthBreakpoint === 'lg' ? BarPosition.Start : BarPosition.End }) {
  // ...
}
.barWidth(this.currentWidthBreakpoint === 'lg' ? '96vp' : '100%')
.barHeight(this.currentWidthBreakpoint === 'lg' ? '100%' : (deviceInfo.deviceType === '2in1' ? '56vp' : '76vp'))
.barMode(this.currentWidthBreakpoint === 'lg' ? BarMode.Scrollable : BarMode.Fixed, { nonScrollableLayoutStyle: LayoutStyle.ALWAYS_CENTER })
.vertical(this.currentWidthBreakpoint === 'lg')

顶部页签及搜索框

不同断点下,顶部页签和搜索框占用不同栅格列数,使用栅格布局实现在sm断点下分两行显示,在md和lg断点下单行显示。

根据设计将栅格在sm、md和lg的断点上分别划分为4列、12列、12列。

代码实现:

build() {
  Column() {
    GridRow({ columns: { sm: 4, md: 12, lg: 12 } }) {
      GridCol({ span: { sm: 4, md: 7, lg: 7 } }) {
        this.TopTabBar()
      }
      .padding({ top: deviceInfo.deviceType === '2in1' ? 0 : $r('app.float.search_top_padding_top') })
      .height(deviceInfo.deviceType === '2in1' ? $r('app.float.search_top_height') : $r('app.float.search_top_height_more'))

      GridCol({ span: { sm: 4, md: 5, lg: 5 } }) {
        this.searchBar()
      }
      .padding({ top: this.currentWidthBreakpoint === 'sm' || deviceInfo.deviceType === '2in1' ? 0 : $r('app.float.search_top_padding_top') })
      .height(this.currentWidthBreakpoint === 'sm' || deviceInfo.deviceType === '2in1' ? $r('app.float.search_top_height') : $r('app.float.search_top_height_more'))
    }
    .backgroundColor(this.scrollHeight >= new BreakpointType(HomeConstants.BACKGROUND_CHANGE_HEIGHT[0], HomeConstants.BACKGROUND_CHANGE_HEIGHT[1], HomeConstants.BACKGROUND_CHANGE_HEIGHT[2]).getValue(this.currentWidthBreakpoint) && this.currentTopIndex === 2 ? $r('app.color.home_content_background') : Color.Transparent)
  }
  .width('100%')
}

随着设备宽度变大,顶部页签间距变大、页面能够展示更多页签内容,使用List组件实现延伸能力;同时使用layoutWeight将增加的空间全部分配给搜索框,实现拉伸能力。

Banner图

Banner图和图标列表区域,均使用Swiper组件,设置在不同断点下的displayCount属性来实现自适应布局的延伸能力。

Banner图区域中,Banner展示数量在sm断点下为1,并显示导航点指示器;在md和lg断点下Banner为2,且前后边距展示前后两张Banner图的部分内容。

在"一多"的应用中,经常会出现窗口大小改变时,如果组件随着窗口宽度变化只改变宽度、不改变高度,会导致图片变形。

为解决这一痛点,需要给Stack组件设置aspectRatio属性,Stack的高度会跟随宽度变化相应等比发生变化,Banner图大小变化且宽高比保持不变,实现自适应布局的缩放能力。

代码实现:

Swiper() {
  LazyForEach(this.bannerDataSource, (item: Banner, index: number) => {
    this.BannerItem(item, index)
  }, (item: Banner, index: number) => `${index}_${item.getBannerImg().getImgSrcSm()}`)
}
.displayCount(this.currentWidthBreakpoint === 'sm' ? 1 : 2)
.itemSpace('12vp')
.cachedCount(3)
.prevMargin(new BreakpointType("0vp", "12vp", "64vp").getValue(this.currentWidthBreakpoint))
.nextMargin(new BreakpointType("0vp", "12vp", "64vp").getValue(this.currentWidthBreakpoint))

推荐视频

视频推荐和新片发布区域,均使用网格布局Grid组件,在不同断点下将父组件分为不同列数,来实现自适应布局的占比能力。

视频推荐区域中,网格布局在sm断点下分2列,md断点下分3列,lg断点下分4列。

代码实现:

// products/phone/src/main/ets/entryability/EntryAbility.ets
private updateWidthBp(): void {
  if (this.windowObj === undefined) { return; }
  try {
    let mainWindow: window.WindowProperties = this.windowObj.getWindowProperties();
    let windowWidth: number = mainWindow.windowRect.width;
    let windowWidthVp = windowWidth / display.getDefaultDisplaySync().densityPixels;
    let widthBp: string = '';
    let videoGridColumn: string = '1fr 1fr';
    if (windowWidthVp < 320) {
      widthBp = 'xs';
      videoGridColumn = '1fr 1fr';
    } else if (windowWidthVp >= 320 && windowWidthVp < 600) {
      widthBp = 'sm';
      videoGridColumn = '1fr 1fr';
    } else if (windowWidthVp >= 600 && windowWidthVp < 840) {
      widthBp = 'md';
      videoGridColumn = '1fr 1fr 1fr';
    } else if (windowWidthVp >= 840 && windowWidthVp < 1440) {
      widthBp = 'lg';
      videoGridColumn = '1fr 1fr 1fr 1fr';
    } else {
      widthBp = 'xl';
      videoGridColumn = '1fr 1fr 1fr 1fr';
    }
    AppStorage.setOrCreate('currentWidthBreakpoint', widthBp);
    AppStorage.setOrCreate('videoGridColumn', videoGridColumn);
  } catch (error) {
    let err = error as BusinessError;
    hilog.error(0x00, 'EntryAbility', `getDefaultDisplaySync failed, code = ${err.code}, message = ${err.message}`);
  }
}

为实现图片大小等比变化,需要给Stack组件设置aspectRatio属性,同Banner图区域,实现自适应布局的缩放能力。

不同的是,因为Grid组件设置了rowsTemplate属性,子组件GridItem均分Grid组件的全部高度,所以Grid组件不能自适应为内容组件的高度,需要用getGridHeight方法先自行计算出Grid组件的高度,从而保证子组件中图片等比放大或缩小。

在getGridHeight方法中,先根据窗口宽度和网格列数,计算出单张图片宽度;再根据图片宽度和宽高比计算出图片高度,并与标题和内容栏高度相加;最后乘网格行数得到Grid组件的总高度。

首页社区页签的沉浸式设计

沉浸式的视频播放和互动作为视频类应用的核心,沉浸式首页的设计在长视频应用中必不可少。

为了给用户带来沉浸式体验,沉浸式界面往往会发生特别的变化:

Banner图覆盖到侧边页签和顶部页签栏。

背景色与Banner图底色保持统一色调。

下滑过程中顶部页签栏背景色更改为统一色调。

文字和图标颜色与背景色为对比色。

解决方案:

将Banner图设置为backgroundImage,并使用Row组件显示文字占位,侧边/底部页签和顶部页签栏使用Tabs和Stack控制层级,并根据设计将背景色设置为透明。

在backgroundImage处设置backgroundColor属性为统一色调的背景色。

在Scroll的onScrollFrameBegin()方法中获取当前y轴滑动偏移量,根据固定偏移量修改顶部页签栏的backgroundColor属性为统一色调的背景色。

相关文字和图标设置颜色时增加条件判断。

首页推荐视频区域长按预览

长按首页推荐视频区域的第一张图片,在图片的位置显示自定义弹窗播放视频,弹窗默认向上方展开。当图片上划至窗口顶部时,弹窗自适应向下方展开。

因为Banner图区域使用了aspectRatio属性来控制图片的宽高比不变,所以不能手动计算视频推荐区域距离窗口顶部的高度。

对这种问题,推荐使用组件标识的getRectangleById方法获取组件相对于应用窗口左上角的水平和垂直方向坐标,获取的位置属性单位为px,使用this.getUIContext().px2vp方法转换单位为vp,从而确定自定义弹窗的偏移量offset。

代码实现:

Stack({ alignContent: Alignment.Center }) {
  // ...
}
.focusable(true)
.width('100%')
.aspectRatio(HomeConstants.VIDEO_DIALOG_ASPECT_RATIO)
.gesture(
  LongPressGesture({ repeat: false })
    .onAction(() => {
      if (index !== 0) { Logger.info(`Please long press the first image`); return; }
      let modePosition: componentUtils.ComponentInfo = this.getUIContext().getComponentUtils().getRectangleById(JSON.stringify(item));
      let windowOffset = modePosition.windowOffset;
      let size = modePosition.size;
      let rectTop: number = this.getUIContext().px2vp(windowOffset.y);
      let rectTop2: number = this.getUIContext().px2vp(windowOffset.y + Math.floor(size.height));
      let rectLeft: number = this.getUIContext().px2vp(windowOffset.x);
      // ...计算弹窗位置
      this.videoDialogController = new CustomDialogController({
        builder: VideoDialog(),
        autoCancel: true,
        customStyle: true,
        alignment: DialogAlignment.TopStart,
        offset: { dx: rectLeft, dy: dialogYOffset }
      });
      this.videoDialogController.open();
    }))

首页推荐视频区域的缩放

在md和lg断点下,首页的推荐视频区域支持双指捏合放大与缩小。

网格布局在md断点下默认3列显示,两指向中心捏合切换为4列显示,两指向外放大切换为3列显示;lg断点下,4列、5列显示同理。

这一效果通过在PinchGesture双指触发捏合手势中动态修改网格布局的columnsTemplate属性实现。

代码实现:

Grid() {
  // ...
}
.gesture(PinchGesture({ fingers: 2 }).onActionUpdate((event: GestureEvent) => {
  if (event.scale > 1 && this.currentWidthBreakpoint !== 'sm') {
    if (this.currentWidthBreakpoint === 'md') {
      this.getUIContext().animateTo({ duration: 500 }, () => { this.videoGridColumn = '1fr 1fr 1fr'; })
    } else {
      this.getUIContext().animateTo({ duration: 500 }, () => { this.videoGridColumn = '1fr 1fr 1fr 1fr'; })
    }
  } else if (event.scale < 1 && this.currentWidthBreakpoint !== 'sm') {
    if (this.currentWidthBreakpoint === 'md') {
      this.getUIContext().animateTo({ duration: 500 }, () => { this.videoGridColumn = '1fr 1fr 1fr 1fr'; })
    } else {
      this.getUIContext().animateTo({ duration: 500 }, () => { this.videoGridColumn = '1fr 1fr 1fr 1fr 1fr'; })
    }
  } else {
    Logger.info(`Two-finger operation is not supported`);
  }
}))

四、视频详情页咋开发

视频详情页具备视频播放、评论互动及展示视频相关信息的功能。

将视频详情页划分为5个区域:

区域1:视频播放。

区域2:相关列表。

区域3:全部评论。

区域4:写评论。

区域5:视频简介(仅lg断点下显示)。

全部评论

根据UX设计,全部评论在sm、md断点下显示在视频下方,在lg断点下显示在右侧边栏,实现侧边悬浮面板。

使用SideBarContainer组件和断点控制组件显示位置。侧边栏宽度可通过拖拽调整。

全部评论中的图片在sm、md断点下使用不同的固定宽高;在lg断点下,通过SideBarContainer的onAreaChange方法按侧边栏宽度变化的百分比调整图片宽高,并使用aspectRatio属性控制等比缩放。

代码实现:

SideBarContainer() {
  Column() {
    Scroll() {
      AllComments({ commentImgHeight: $commentImgHeight, commentImgWidth: $commentImgWidth })
        .visibility(this.currentWidthBreakpoint === 'lg' ? Visibility.Visible : Visibility.None)
    }
    .align(Alignment.Top)
    .scrollBar(BarState.Off)
    .layoutWeight(1)
    .width('100%')
    .padding({ bottom: '12vp' })
    if(this.currentWidthBreakpoint === 'lg'){
      SelfComment()
    }
  }
  .height('100%')
  .width('100%')
  .backgroundColor(Color.White)
  .onAreaChange((newValue: Area) => {
    if (newValue.width !== 0) {
      let height: number = 150 + (Number(newValue.width) - 320) / (this.getUIContext().px2vp(this.windowWidth) * 0.4 - 320) * (182 - 150);
      let width: number = 219 + (Number(newValue.width) - 320) / (this.getUIContext().px2vp(this.windowWidth) * 0.4 - 320) * (266 - 219);
      this.commentImgHeight = JSON.stringify(height);
      this.commentImgWidth = JSON.stringify(width);
    }
  })

  Column() {
    VideoDetailView({
      screenHeight: this.screenHeight,
      relatedVideoHeight: this.relatedVideoHeight,
      videoHeight: this.videoHeight
    })
      .layoutWeight(1)
    if(!(this.currentWidthBreakpoint === 'lg' || this.isFullScreen)){
      SelfComment()
    }
  }
  .height('100%')
  .width('100%')
}
.showSideBar(this.currentWidthBreakpoint === 'lg' && !this.isFullScreen ? true : false)
.showControlButton(false)
.autoHide(false)
.sideBarPosition(SideBarPosition.End)
.sideBarWidth($r('app.float.side_bar_min_width'))
.minSideBarWidth($r('app.float.side_bar_min_width'))
.maxSideBarWidth(this.getUIContext().px2vp(this.windowWidth * 0.4))

边看边评的交互逻辑

视频详情页中,不同断点下页面下滑效果不同。

在sm和md断点下,下滑时先隐藏相关列表区域,完全隐藏后视频区域等比缩小,缩至最小时固定,改为下滑全部评论区域;上滑时先上滑全部评论区,再等比放大视频区域,最后显示相关列表区域。

在lg断点下,下滑时等比缩小视频区域,最小时固定改为下滑全部评论区域;上滑时先上滑全部评论区,再等比放大视频区域。

这一效果通过Scroll的onScrollFrameBegin回调方法中控制相关组件的高度和偏移量实现。

当组件高度变化时,返回偏移量为0;当全部评论区滑动时,返回实际偏移量。

代码实现:

build() {
  Scroll(this.scroller) {
    Column() {
      this.RelatedVideoComponent()
      this.VideoIntroduction()
      AllComments({commentImgHeight: this.commentImgHeight, commentImgWidth: this.commentImgWidth})
        .visibility(this.currentWidthBreakpoint === 'lg' ? Visibility.None : Visibility.Visible)
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding({ bottom: '10vp' })
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)
  .visibility(!this.isFullScreen ? Visibility.Visible : Visibility.None)
  .onScrollFrameBegin((offset: number) => {
    if (this.currentWidthBreakpoint === 'lg') {
      if ((offset > 0) && (this.videoHeight > 53)) {
        let offsetPercent = (Math.abs(offset) * 100) / this.screenHeight;
        let heightOffset = offsetPercent < this.videoHeight - 53 ? offsetPercent : this.videoHeight - 53;
        this.videoHeight = this.videoHeight - heightOffset;
        return { offsetRemain: 0 };
      } else if ((offset < 0) && (this.videoHeight < 100) && (CurrentOffsetUtil.scrollToTop(JSON.stringify(this.scroller.currentOffset())))) {
        let offsetPercent = (Math.abs(offset) * 100) / this.screenHeight;
        let heightOffset = offsetPercent < 100 - this.videoHeight ? offsetPercent : 100 - this.videoHeight;
        this.videoHeight = this.videoHeight + heightOffset;
        return { offsetRemain: 0 };
      }
      return { offsetRemain: offset };
    } else {
      // sm和md断点下的逻辑...
    }
  })
}

五、全屏播放页咋开发

全屏播放页为用户提供沉浸式观看体验,并支持选集功能。

将全屏播放页划分为3个区域:

区域1:全屏视频播放。

区域2:进度条及工具栏。

区域3:选集列表。

全屏视频播放

进入全屏播放页,使用工具类windowUtil提供的disableWindowSystemBar()方法隐藏顶部导航栏和状态栏,并使用setMainWindowOrientation()方法在手机和折叠屏折叠态下设置窗口横屏播放。

代码实现:

private onFullScreenChange(): void {
  if (((this.currentWidthBreakpoint === 'md' && this.currentHeightBreakpoint !== 'sm') || this.currentWidthBreakpoint === 'lg') && !this.isHalfFolded) {
    this.windowUtil?.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
  } else if (this.currentWidthBreakpoint === 'sm' && this.currentHeightBreakpoint === 'lg') {
    if (this.isFullScreen) {
      this.windowUtil?.setMainWindowOrientation(window.Orientation.AUTO_ROTATION_LANDSCAPE_RESTRICTED);
    } else {
      this.windowUtil?.setMainWindowOrientation(window.Orientation.PORTRAIT);
    }
  } else if (this.currentWidthBreakpoint === 'md' && this.currentHeightBreakpoint === 'sm' && !this.isFullScreen) {
    this.windowUtil?.setMainWindowOrientation(window.Orientation.PORTRAIT);
  }
  if (deviceInfo.deviceType !== '2in1') {
    if (this.isFullScreen) {
      this.windowUtil!.disableWindowSystemBar();
    } else {
      this.windowUtil!.enableWindowSystemBar();
    }
  }
}

视频悬停播放

在折叠屏上浏览视频详情页或全屏播放页时,旋转设备至横屏并折叠设备至半折叠态,将自动切换至悬停态的沉浸播放视频体验。

折叠屏悬停适配通过window的setPreferredOrientation接口设置窗口横向显示,通过display的getCurrentFoldCreaseRegion接口获取折叠屏折痕区域的位置和大小,通过自定义方式实现悬停态页面。

视频移到上半屏,中间为折叠屏避让区,其他可操作组件堆叠到下半屏。

代码实现:

export class DisplayUtil {
  static getFoldCreaseRegion(uiContext: UIContext): void {
    if (canIUse('SystemCapability.Window.SessionManager')) {
      try {
        if (display.isFoldable()) {
          let foldRegion: display.FoldCreaseRegion = display.getCurrentFoldCreaseRegion();
          let rect: display.Rect = foldRegion.creaseRects[0];
          let creaseRegion: number[] = [uiContext!.px2vp(rect.top), uiContext!.px2vp(rect.height)];
          AppStorage.setOrCreate('creaseRegion', creaseRegion);
        }
      } catch (error) {
        let err = error as BusinessError;
        hilog.error(0x00, 'DisplayUtil', `geetIsFoldable failed, code = ${err.code}, message = ${err.message}`);
      }
    }
  }
}

六、避坑指南

坑1:图片变形

应用窗口宽度改变时,如果组件随着窗口宽度变化只改变宽度、不改变高度,会导致图片变形。

咋解决?给Stack组件设置aspectRatio属性,Stack的高度会跟随宽度变化相应等比发生变化,图片宽高比保持不变。

坑2:容器高度动态变化

图片等比改变大小导致容器组件的高度实时变化,Grid组件设置了rowsTemplate属性后,子组件GridItem均分Grid组件的全部高度,Grid组件不能自适应为内容组件的高度。

咋解决?用getGridHeight方法先自行计算出Grid组件的高度,从而保证子组件中图片等比放大或缩小。

坑3:获取组件坐标

获取不到特定组件在应用窗口内的坐标,不能手动计算视频推荐区域距离窗口顶部的高度。

咋解决?使用组件标识的getRectangleById方法获取组件相对于应用窗口左上角的水平和垂直方向坐标,获取的位置属性单位为px,使用px2vp方法转换单位为vp。

坑4:自定义滑动效果

多设备下自定义滑动效果,不同断点下页面下滑效果不同。

咋解决?通过Scroll的onScrollFrameBegin回调方法中控制相关组件的高度和偏移量实现。当组件高度变化时,返回偏移量为0;当全部评论区滑动时,返回实际偏移量。

坑5:折叠屏悬停态

折叠屏悬停态页面开发无从下手,不知道咋获取折痕区域的位置和大小。

咋解决?通过display的getCurrentFoldCreaseRegion接口获取折叠屏折痕区域的位置和大小,视频移到上半屏,中间为折叠屏避让区,其他可操作组件堆叠到下半屏。

坑6:沉浸式设计

首页沉浸式设计页面的背景图与透视效果难以实现,Banner图覆盖到侧边页签和顶部页签栏。

咋解决?将Banner图设置为backgroundImage,侧边/底部页签和顶部页签栏使用Tabs和Stack控制层级,并根据设计将背景色设置为透明。在Scroll的onScrollFrameBegin()方法中获取当前y轴滑动偏移量,根据固定偏移量修改顶部页签栏的backgroundColor属性为统一色调的背景色。

七、总结

长视频应用的多设备开发,从工程管理、页面开发、交互逻辑、悬停态适配等方面,都有值得学习的最佳实践。

工程管理方面,三层架构划分清晰,产品定制层、基础特性层、公共能力层各司其职,便于维护和复用。

页面开发方面,响应式布局是核心,栅格组件、List组件、Grid组件、Swiper组件都有不同的用法。关键是根据不同断点设置不同的属性值,实现自适应布局。

交互逻辑方面,边看边评的效果通过Scroll的onScrollFrameBegin回调方法控制组件高度和偏移量实现,需要理解滚动事件的处理机制。

悬停态适配方面,折叠屏的悬停态通过display的getCurrentFoldCreaseRegion接口获取折痕区域的位置和大小,自定义实现悬停态页面。

最后,长视频应用的核心是沉浸式体验,首页的沉浸式设计、全屏播放页的横屏显示、折叠屏的悬停播放,都是为了提升用户体验。开发时要多关注这些细节,才能做出好的产品。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐