HarmonyOS 长视频应用多设备开发实战:从首页到全屏播放完整方案
本文介绍了长视频应用在多设备开发中的最佳实践,从工程管理、页面开发等角度提供解决方案。在工程架构上采用产品定制层、基础特性层、公共能力层的三层目录划分,实现模块独立和代码复用。针对首页开发,通过栅格组件、Tabs组件、Swiper组件等实现响应式布局,解决图片变形、组件高度变化等痛点问题。具体实践包括:底部/侧边页签的位置切换、顶部页签与搜索框的布局适配、Banner图等比缩放、推荐视频网格布局等
一、这玩意儿是啥
长视频应用的核心功能是沉浸式的视频播放和互动,包括首页推荐、视频搜索、视频详情、视频评论、全屏播放等。
开发这类应用,经常遇到这些痛难点问题:
平级工程目录逻辑混乱,依赖关系不清晰。
应用窗口宽度改变时图片变形。
图片等比改变大小导致容器组件高度实时变化。
首页沉浸式设计页面的背景图与透视效果难以实现。
获取不到特定组件在应用窗口内的坐标。
如何适配多设备下自定义滑动效果。
折叠屏悬停态页面开发无从下手。
本文从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接口获取折痕区域的位置和大小,自定义实现悬停态页面。
最后,长视频应用的核心是沉浸式体验,首页的沉浸式设计、全屏播放页的横屏显示、折叠屏的悬停播放,都是为了提升用户体验。开发时要多关注这些细节,才能做出好的产品。
更多推荐


所有评论(0)