一多开发实例(长视频)概述
本文从目前流行的垂类市场中,选择长视频行业应用作为典型案例详细介绍[“一多”]在实际开发中的应用。长视频行业应用的核心功能为沉浸式的视频播放和互动,主要包含首页推荐、视频搜索、视频详情、视频评论、全屏播放等。根据这些核心功能,本文选择[首页]、[搜索页]、[视频详情页]和[全屏播放页]作为典型页面进行开发,遵从多设备的“差异性”、“一致性”、“灵活性”和“兼容性”,能够让开发者快速高效地掌握“一多”能力并实现长视频应用的相关功能。
当前系统的产品形态主要有手机、折叠屏、平板和2in1四种,下文的具体实践也将围绕这几种产品形态展开,同时将分别从UX设计、工程管理、页面开发和功能开发四个角度给出符合“一多”的参考样例,介绍“一多”长视频应用在开发过程中的最佳实践。
UX设计
影音娱乐类的多设备响应式设计指南。
工程管理
本章将介绍如何创建“一多”工程及划分目录结构。
创建工程
根据三层架构创建系统工程,先创建出最基本的项目工程,再在基本目录结构的基础上进行修改。
工程结构
开发者在创建“一多”的工程时,会遇到如何划分工程结构目录的问题。考虑到工程的复用性和可维护性,本文以长视频应用为例给出推荐的参考方案。
HarmonyOS的分层架构主要包括三个层次:产品定制层、基础特性层和公共能力层,为开发者构建了一个清晰、高效、可扩展的设计架构
长视频应用根据一多推荐的commons、features、products的”三层工程架构“划分目录。其中四个页面功能不同,互不依赖,根据页面划分为四个features(基础特性层):首页-home、视频搜索页-search、视频详情页-videoDetail和全屏播放页-videoPlayer。公共常量、媒体播放工具以及窗口管理工具等需要被不同页面依赖引用的内容,划分为一个commons(公共能力层):基础能力-base。其中features层不同页面的功能相对独立、互不影响,推荐创建HAR包;commons层存放公共能力类,被features层和products层依赖,推荐创建HAR包。
工程结构如下:
├──commons // 公共能力层
│ ├──base/src/main/ets // 基础能力
│ │ ├──constants
│ │ └──utils
│ └──base/src/Index.ets // 对外接口类
├──features // 基础特性层
│ ├──home/src/main/ets // 首页
│ │ ├──constants
│ │ ├──utils
│ │ ├──view
│ │ └──viewmodel
│ ├──home/src/main/resources // 资源文件目录
│ ├──home/src/Index.ets // 对外接口类
│ ├──search/src/main/ets // 搜索页
│ │ ├──constants
│ │ ├──view
│ │ └──viewmodel
│ ├──search/src/main/resources // 资源文件目录
│ ├──search/src/Index.ets // 对外接口类
│ ├──videoDetail/src/main/ets // 视频详情页
│ │ ├──constants
│ │ ├──utils
│ │ ├──view
│ │ └──viewmodel
│ ├──videoDetail/src/main/resources // 资源文件目录
│ ├──videoPlayer/src/main/ets // 全屏播放页
│ │ ├──constants
│ │ └──view
│ └──videoPlayer/src/main/resources // 资源文件目录
└──products // 产品定制层
├──phone/src/main/ets // 支持手机、折叠屏、平板、2in1
│ ├──entryability
│ └──pages
└──phone/src/main/resources // 资源文件目录
页面开发
本章介绍长视频应用中如何使用“一多”的布局能力,完成页面层级的一套页面、多端适配。同时介绍长视频应用中的[交互开发]和推荐的[资源使用]方式。
首页
长视频应用首页主要发挥推荐精选视频的作用,解决用户想要看视频的核心需求,所以首页内容都围绕这一功能设计。观察首页在2in1上的UX设计图,可以进行如下设计(图中为包括可滑动区域的内容):
- 将应用首页划分为8个区域,效果图如下:
-
整个页面响应式适配,借助栅格组件能力监听不同断点变化实现不同的布局效果。
-
首页区域2在小设备上呈两行显示,在中设备和大设备上单行显示,断点变化时切换显示效果。
-
首页区域3、4使用自适应布局延伸能力随不同设备尺寸延伸或隐藏。
-
首页区域1,5-8使用响应式布局中的栅格断点系统,根据断点变化切换改变组件内相应的属性实现布局效果。
长视频应用搜索页的8个基础区域介绍及实现方案如下表所示:
区域编号 | 简介 | 实现方案 |
---|---|---|
1 | [底部/侧边页签] | 借助[栅格布局]监听断点变化改变位置。 |
2 | [顶部页签及搜索框] | 栅格布局监听断点变化实现折行显示,[List组件]实现延伸能力,layoutWeight实现拉伸能力。 |
3 | [Banner图] | [Swiper组件],指定displayCount属性实现延伸能力,设置aspectRatio属性实现缩放能力。 |
4 | 图标列表 | Swiper组件,指定displayCount属性实现自适应布局延伸能力,设置aspectRatio属性实现缩放能力。 |
5 | [推荐视频] | [网格容器],借助栅格组件能力监听断点变化改变列数,设置aspectRatio属性实现缩放能力。 |
6 | 新片发布 | 网格容器,借助栅格组件能力监听断点变化改变列数,设置aspectRatio属性实现缩放能力。 |
7 | [每日佳片] | 利用响应式布局的栅格布局,结合[Stack组件]和[Grid组件],设置aspectRatio属性实现缩放能力。 |
8 | 往期回顾 | 响应式布局的栅格布局,设置aspectRatio属性实现缩放能力。 |
在实际开发中,区域1为外层导航栏,区域2为内层导航栏,区域3-8为并列的首页内容,所以对应的开发顺序为区域1、区域2和区域3-8。另外,为了提升用户的使用体验,首页设计了额外的功能,包括[首页社区页签的沉浸式设计],[2in1首页Banner图的排版创新],[首页推荐视频区域长按预览],[首页推荐视频区域的缩放]。
-
底部/侧边页签区域,使用Tabs组件,设置在不同断点下的vertical属性,实现显示在首页的不同位置。在sm和md断点下,页签显示在底部,高度为56vp;在lg断点下页签显示在左侧,宽度为96vp,且页签居中显示。
示意图如下:
// features/home/src/main/ets/view/Home.ets
// 底部/侧边页签区域
Tabs({
// lg断点时,页签栏在侧边;sm、md断点时,页签栏在底部
barPosition: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? BarPosition.Start : BarPosition.End
}) {
// ...
}
// 底部页签大小的变换
.barWidth(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? $r('app.float.bottom_tab_bar_width_lg') :
CommonConstants.FULL_PERCENT)
.barHeight(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? CommonConstants.FULL_PERCENT :
(deviceInfo.deviceType === CommonConstants.DEVICE_TYPES[0] ? $r('app.float.tab_size_lg') :
$r('app.float.tab_size')))
// 设置不同断点下页签的布局模式
.barMode(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ? BarMode.Scrollable : BarMode.Fixed,
{ nonScrollableLayoutStyle: LayoutStyle.ALWAYS_CENTER })
// lg断点时为纵向Tabs,sm、md断点时为横向Tabs
.vertical(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG)
// features/home/src/main/ets/view/HomeHeader.ets
// 顶部页签及搜索框
build() {
GridRow({
columns: {
// 栅格数4、12、12列
sm: CommonConstants.GRID_ROW_COLUMNS[2],
md: CommonConstants.GRID_ROW_COLUMNS[0],
lg: CommonConstants.GRID_ROW_COLUMNS[0]
}
}) {
GridCol({
span: {
// 顶部页签占用4、7、7列
sm: CommonConstants.GRID_COLUMN_SPANS[5],
md: CommonConstants.GRID_COLUMN_SPANS[2],
lg: CommonConstants.GRID_COLUMN_SPANS[2]
}
}) {
this.TopTabBar()
}
GridCol({
span: {
// 搜索框占用4、5、5列
sm: CommonConstants.GRID_COLUMN_SPANS[5],
md: CommonConstants.GRID_COLUMN_SPANS[3],
lg: CommonConstants.GRID_COLUMN_SPANS[3]
}
}) {
this.searchBar()
}
}
}
随着设备宽度变大,顶部页签间距变大、页面能够展示更多页签内容,使用List组件实现延伸能力;同时使用layoutWeight将增加的空间全部分配给搜索框,实现拉伸能力。
// features/home/src/main/ets/view/HomeHeader.ets
// 顶部页签
@Builder
TopTabBar() {
Row() {
Column() {
List({
// 随着断点变大,页签间距变大
space: new BreakpointType(HomeConstants.SEARCH_TAB_LIST_SPACES[0], HomeConstants.SEARCH_TAB_LIST_SPACES[1],
HomeConstants.SEARCH_TAB_LIST_SPACES[2]).getValue(this.currentBreakpoint)
}) {
...
}
}
}
}
// 搜索框
@Builder
searchBar() {
Row() {
Stack({ alignContent: Alignment.Start }) {
// ...
}
.alignSelf(ItemAlign.Center)
// 增加的空间全部分配给搜索框
.layoutWeight(1)
}
}
-
Banner图和图标列表区域,均使用Swiper组件,设置在不同断点下的displayCount属性来实现自适应布局的延伸能力,本章节以Banner图区域作为示例,图标列表的实现读者可以自行查看代码。Banner图区域中,Banner展示数量在sm断点下为1,并显示导航点指示器;在md和lg断点下Banner为2,且前后边距展示前后两张Banner图的部分内容。
在“一多”的应用中,经常会出现窗口大小改变如果组件随着窗口宽度变化只改变宽度、不改变高度,会导致图片变形,视觉上会给用户带来较差体验。为解决这一痛点,需要给Stack组件设置aspectRatio属性,Stack的高度会跟随宽度变化相应等比发生变化,Banner图大小变化且宽高比保持不变,实现自适应布局的缩放能力。
示意图如下:
// features/home/src/main/ets/view/BannerView.ets
// Banner图区域
Swiper() {
LazyForEach(this.bannerDataSource, (item: Banner, index: number) => {
Column() {
Stack() {
// ...
}
.height(item.getBannerImg().getHeight().getValue(this.currentBreakpoint))
.width(CommonConstants.FULL_PERCENT)
// 宽高按照预设的比例,随容器组件发生变化且宽高比不变
.aspectRatio(new BreakpointType(HomeConstants.BANNER_RATIOS[0], HomeConstants.BANNER_RATIOS[1],
HomeConstants.BANNER_RATIOS[2]).getValue(this.currentBreakpoint))
}
}, (item: Banner, index: number) => index + JSON.stringify(item))
}
// ...
.index(2)
// 设置不同断点下的Banner展示数量,实现自适应布局的延伸能力
.displayCount(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? 1 : HomeConstants.TWO)
.itemSpace(HomeConstants.SWIPER_ITEM_SPACE)
// 设置是否显示导航点指示器
.indicator(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? Indicator.dot()
.itemWidth($r('app.float.swiper_item_size'))
.itemHeight($r('app.float.swiper_item_size'))
.selectedItemWidth($r('app.float.swiper_selected_item_width'))
.selectedItemHeight($r('app.float.swiper_item_size'))
.color($r('app.color.swiper_indicator'))
.selectedColor(Color.White) : false
)
.loop(true)
.width(CommonConstants.FULL_PERCENT)
.visibility((this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) && (this.currentTopIndex === 1) ?
Visibility.None : Visibility.Visible)
.effectMode(EdgeEffect.None)
// md与lg设备上前后露出两张Banner图的大小不同
.prevMargin(new BreakpointType($r('app.float.swiper_prev_next_margin_sm'),
$r('app.float.swiper_prev_next_margin_md'), $r('app.float.swiper_prev_next_margin_lg'))
.getValue(this.currentBreakpoint))
.nextMargin(new BreakpointTy