《新闻资讯》五、直播模块实现指南
HarmonyOS NEXT 新闻资讯应用 · 直播模块 features/live 实现指南
开发环境:DevEco Studio 6.1.0 Release
SDK版本:HarmonyOS SDK 6.1.0(23) / API 23
开发语言:ArkTS
状态管理:V2(@ComponentV2系列装饰器)
前置阅读:common模块指南 · 整体架构指南
本篇详细讲解直播模块features/live的实现。作为应用第三个 Tab 页,直播模块是最精简的 feature 模块——仅有 1 个组件LiveHome、1 个源文件、112 行源码,没有子页面跳转。麻雀虽小五脏俱全,它完整展示了LazyForEach + CommonDataSource 懒加载、LOCAL_LIVE_DATA 本地数据、自定义 Tab 栏 + 数据源切换等核心设计模式。
重要实践变更:经过实际调试,直播模块做了以下关键调整:
- 数据本地化:不再从 common 导入
LIVE_DATA(跨模块运行时不可用),改为模块内定义LOCAL_LIVE_DATA- 单一数据源:使用 1 个
@Local CommonDataSource<LiveItem>+setData()切换 Tab 数据- 自定义 Tab 栏:用 Row + Text 替代嵌套 Tabs,避免嵌套 Tabs 布局冲突
- LazyForEach:替代 ForEach + 原生数组,配合 CommonDataSource 实现懒加载
效果
一、模块定位与架构角色
直播模块在三层架构中处于基础特性层(features),编译为 HAR 包:
产品定制层 product/phone (HAP) ↓ 依赖 基础特性层 features/news | features/video | features/live ← 本模块 | features/personal | features/service (HAR) ↓ 依赖 公共能力层 common (HAR)模块职责:提供直播内容浏览功能,仅包含 1 个组件:
| 组件 | 文件 | 行数 | 类型 | 功能 |
|---|---|---|---|---|
LiveHome | components/LiveHome.ets | 112 | @ComponentV2 | 直播主页,自定义 Tab 栏 + LazyForEach + CommonDataSource |
与其他 feature 模块的区别:直播模块没有 NavDestination 子页面(不需要路由跳转),因此不需要@Consumer('pageStack'),也不导出任何 pages 目录下的组件。
二、模块配置详解
2.1 oh-package.json5
{ "name": "live", "version": "1.0.0", "description": "Please describe the basic information.", "main": "Index.ets", "author": "", "license": "Apache-2.0", "dependencies": { "common": "file:../../common" } }与其他 features 模块配置完全一致,仅name不同。common依赖路径"file:../../common"从features/live向上两级到common目录。
2.2 Index.ets 入口文件
export{LiveHome}from'./src/main/ets/components/LiveHome';仅 2 行,导出唯一的组件LiveHome。外部通过import { LiveHome } from 'live'使用。
三、完整文件结构树
features/live/ ├── oh-package.json5 (12行) 模块配置 ├── Index.ets (2行) 统一导出 ├── src/main/ │ ├── ets/ │ │ └── components/ │ │ └── LiveHome.ets (112行) 直播主页,自定义Tab+LazyForEach │ └── resources/base/media/ (live_01/02/03.png 等直播封面图)这是所有 feature 模块中结构最简单的——没有pages/目录,没有 NavDestination 子页面。
四、数据源设计:LOCAL_LIVE_DATA 本地化方案
重要变更:原方案从 common 模块导入
LIVE_DATA,但实际调试发现跨模块导入的 @ObservedV2 对象数组在运行时无法正常使用(filter 返回空数组)。因此改为在 live 模块内本地定义LOCAL_LIVE_DATA。
4.1 LiveItem 数据模型回顾
@ObservedV2exportclassLiveItem{@TraceliveId:string='';@Tracetitle:string='';@Tracecontent:string='';@TracecoverImage:Resource=$r('app.media.live_01');@Tracecategory:string='关注';@TraceisLive:boolean=false;@TraceviewerCount:number=0;constructor(title:string,content:string,img:Resource,category:string='关注'){this.title=title;this.content=content;this.coverImage=img;this.category=category;}}7 个 @Trace 属性,构造器 3 必选 + 1 可选(category 默认为'关注')。
4.2 LIVE_DATA 常量数组
exportconstLIVE_DATA:LiveItem[]=[newLiveItem('我的关注','大理洱海边',$r('app.media.live_01')),newLiveItem('我的关注','可可里西',$r('app.media.live_02')),newLiveItem('我的关注','浪漫土耳其',$r('app.media.live_03')),newLiveItem('热门推荐','大理洱海边',$r('app.media.live_01')),newLiveItem('热门推荐','可可里西',$r('app.media.live_02')),newLiveItem('热门推荐','浪漫土耳其',$r('app.media.live_03')),newLiveItem('今日直播','大理洱海边',$r('app.media.live_01')),newLiveItem('今日直播','可可里西',$r('app.media.live_02')),newLiveItem('今日直播','浪漫土耳其',$r('app.media.live_03')),newLiveItem('精彩回放','大理洱海边',$r('app.media.live_01')),newLiveItem('精彩回放','可可里西',$r('app.media.live_02')),newLiveItem('精彩回放','浪漫土耳其',$r('app.media.live_03')),]共 12 条数据,按 4 个分类各 3 条组织。
4.3 数据分布表格
| 分类值 | 条数 | title 字段 | content 字段 |
|---|---|---|---|
| 我的关注 | 3 | ‘我的关注’ | 大理洱海边 / 可可里西 / 浪漫土耳其 |
| 热门推荐 | 3 | ‘热门推荐’ | 大理洱海边 / 可可里西 / 浪漫土耳其 |
| 今日直播 | 3 | ‘今日直播’ | 大理洱海边 / 可可里西 / 浪漫土耳其 |
| 精彩回放 | 3 | ‘精彩回放’ | 大理洱海边 / 可可里西 / 浪漫土耳其 |
4.4 为什么使用 LOCAL_LIVE_DATA 而非导入 LIVE_DATA?
| 对比维度 | 原方案(导入 LIVE_DATA) | 现方案(LOCAL_LIVE_DATA) |
|---|---|---|
| 数据来源 | import { LIVE_DATA } from 'common' | 模块内const LOCAL_LIVE_DATA |
| 运行时可用 | ❌ 跨模块 @ObservedV2 数组 filter 返回空 | ✅ 模块内直接可用 |
| 资源引用 | $r 引用在 common 模块解析 | $r 引用在 live 模块本地解析 |
| 维护性 | 数据集中管理 | 数据分散但独立 |
直播模块选择静态数据是合理的——直播列表通常是实时拉取的,Mock 阶段用固定数据足以验证 UI 布局。
五、LiveHome 直播主页
5.1 完整源码
import{LiveItem,StyleConstants,CommonDataSource}from'common';/** * 直播数据 - 在 live 模块本地定义,避免跨模块导入 LIVE_DATA 的运行时问题 */constLOCAL_LIVE_DATA:LiveItem[]=[newLiveItem('我的关注','大理洱海边',$r('app.media.live_01'),'我的关注'),newLiveItem('我的关注','可可里西',$r('app.media.live_02'),'我的关注'),newLiveItem('我的关注','浪漫土耳其',$r('app.media.live_03'),'我的关注'),newLiveItem('热门推荐','大理洱海边',$r('app.media.live_01'),'热门推荐'),newLiveItem('热门推荐','可可里西',$r('app.media.live_02'),'热门推荐'),newLiveItem('热门推荐','浪漫土耳其',$r('app.media.live_03'),'热门推荐'),newLiveItem('今日直播','大理洱海边',$r('app.media.live_01'),'今日直播'),newLiveItem('今日直播','可可里西',$r('app.media.live_02'),'今日直播'),newLiveItem('今日直播','浪漫土耳其',$r('app.media.live_03'),'今日直播'),newLiveItem('精彩回放','大理洱海边',$r('app.media.live_01'),'精彩回放'),newLiveItem('精彩回放','可可里西',$r('app.media.live_02'),'精彩回放'),newLiveItem('精彩回放','浪漫土耳其',$r('app.media.live_03'),'精彩回放'),];@ComponentV2exportstruct LiveHome{@LocalcurrentIndex:number=0;@LocaldataSource:CommonDataSource<LiveItem>=newCommonDataSource<LiveItem>();aboutToAppear(){this.loadTabData(0);}loadTabData(index:number){letcategory:string='我的关注';if(index===1){category='今日直播';}elseif(index===2){category='热门推荐';}this.dataSource.setData(LOCAL_LIVE_DATA.filter((item:LiveItem)=>item.category===category));}build(){Column(){Text('直播')// 顶部标题.fontSize(20).fontWeight(FontWeight.Bold).fontColor(StyleConstants.TEXT_PRIMARY).margin({left:16,top:12,bottom:8}).width('100%')Row(){// 自定义Tab栏Text('关注').fontSize(14).fontWeight(this.currentIndex===0?FontWeight.Bold:FontWeight.Normal).fontColor(this.currentIndex===0?StyleConstants.TEXT_PRIMARY:StyleConstants.TEXT_SECONDARY).padding({left:16,right:16,top:8,bottom:8}).onClick(()=>{this.currentIndex=0;this.loadTabData(0);})// ... '直播'、'热门' 同理}.width('100%')List(){// 内容区域LazyForEach(this.dataSource,(item:LiveItem)=>{ListItem(){Column(){Row(){Text(item.title).fontSize(14).fontColor(Color.Gray).margin({left:6})Divider().vertical(true).color(Color.Grey).height(12).margin({left:8,right:8}).strokeWidth(1)Text(item.content).fontSize(14).fontWeight(FontWeight.Medium)}.width('100%')Image(item.coverImage).width('100%').height(180).margin({top:12,bottom:12,left:6}).borderRadius(4).objectFit(ImageFit.Cover)Divider().strokeWidth(1).margin({top:12,bottom:12,left:6})}.margin({left:16,right:16})}},(item:LiveItem)=>item.title+item.content)}.scrollBar(BarState.Off).layoutWeight(1).width('100%')}.width('100%').height('100%').backgroundColor(Color.White)}}5.2 分段深度讲解
段落1 — 导入与本地数据定义(第1-19行)
导入语句:从common导入 3 个内容:
| 导入项 | 类型 | 用途 |
|---|---|---|
LiveItem | 数据模型 | 直播数据项,@ObservedV2 类 |
StyleConstants | 样式常量 | 颜色、圆角等统一样式 |
CommonDataSource | IDataSource | 为 LazyForEach 提供数据源 |
注意没有导入LIVE_DATA——直播模块在本地定义LOCAL_LIVE_DATA常量数组,避免跨模块 @ObservedV2 对象数组运行时不可用的问题。
状态变量:
| 装饰器 | 变量 | 类型 | 说明 |
|---|---|---|---|
@Local | currentIndex | number | 当前选中 Tab 索引 |
@Local | dataSource | CommonDataSource<LiveItem> | 单一数据源,切换Tab时更新 |
与 news/video 的关键设计:直播模块现在也使用CommonDataSource+LazyForEach,与 news/video 保持一致的模式。
段落2 — loadTabData 数据切换(第31-43行)
loadTabData(index:number){letcategory:string='我的关注';if(index===1){category='今日直播';}elseif(index===2){category='热门推荐';}this.dataSource.setData(LOCAL_LIVE_DATA.filter((item:LiveItem)=>item.category===category));}单一数据源模式:
- Tab 切换时调用
loadTabData(index) - 对
LOCAL_LIVE_DATA按categoryfilter,得到新数组 setData()内部调用notifyDataReload(),触发 LazyForEach 重新渲染
与旧方案对比:
| 对比维度 | 旧方案(3个数组) | 新方案(1个数据源) |
|---|---|---|
| 数据源数量 | 3 个 @Local 数组 | 1 个 @Local CommonDataSource |
| Tab 切换 | Tabs 自动切换 TabContent | 更新数据源 + LazyForEach 重绘 |
| 内存占用 | 3 份数据同时存在 | 仅 1 份当前数据 |
段落3 — 自定义 Tab 栏(第55-84行)
用Row + Text代替嵌套Tabs,避免布局冲突:
Row(){Text('关注').fontSize(14).fontWeight(this.currentIndex===0?FontWeight.Bold:FontWeight.Normal).fontColor(this.currentIndex===0?StyleConstants.TEXT_PRIMARY:StyleConstants.TEXT_SECONDARY).onClick(()=>{this.currentIndex=0;this.loadTabData(0);})// ... '直播'、'热门' 同理}.width('100%')为什么不用嵌套 Tabs?
外层 MainPage 已经有一个Tabs(首页/视频/直播/我的),如果在 LiveHome 内再嵌套Tabs,会导致嵌套 Tabs 布局冲突,内容区域无法正确渲染。自定义 Tab 栏通过@Local currentIndex+ 条件样式实现同样效果。
段落4 — build() 主布局(第45-110行)
布局层级:
Column (根容器) ├── Text('直播') ← 20号加粗标题 ├── Row (自定义Tab栏) ← 关注/直播/热门 └── List + LazyForEach ← 卡片列表 └── ListItem → Column ├── Row (title + Divider + content) ├── Image (100%宽, 180高, 圆角4) └── Divider与 VideoHome 的设计差异:
| 特性 | VideoHome | LiveHome |
|---|---|---|
| Tab 实现 | Tabs + @Builder TabLabel | 自定义 Row + Text |
| 列表布局 | Grid + LazyForEach | List + LazyForEach |
| 数据源 | 1个 CommonDataSource | 1个 CommonDataSource |
| 卡片内容 | @Builder + LazyForEach | 直接内联在 LazyForEach |
六、自定义 Tab 栏 vs 嵌套 Tabs 设计决策
6.1 为什么不用嵌套 Tabs?
外层 MainPage 已有Tabs(首页/视频/直播/我的),在 LiveHome 内嵌套Tabs会导致:
| 问题 | 说明 |
|---|---|
| 布局冲突 | 嵌套 Tabs 的内容区域无法正确分配高度 |
| 事件冲突 | 内外层 Tabs 的滑动手势相互干扰 |
| 内容不显示 | TabContent 内的列表内容无法渲染 |
6.2 自定义 Tab 栏方案
使用Row + Text模拟 Tab 栏,配合@Local currentIndex管理选中态:
Row(){Text('关注').fontWeight(this.currentIndex===0?FontWeight.Bold:FontWeight.Normal).fontColor(this.currentIndex===0?TEXT_PRIMARY:TEXT_SECONDARY).onClick(()=>{this.currentIndex=0;this.loadTabData(0);})}优点:避免嵌套布局冲突、完全控制样式和交互、切换时更新数据源而非切换组件。
七、与 news/video 模块设计差异对比
| 特性 | news 新闻 | video 视频 | live 直播 |
|---|---|---|---|
| 组件数量 | 4(+2页面) | 2(+1页面) | 1(无子页面) |
| 源码行数 | 551 | 212 | 112 |
| 数据来源 | rawfile JSON 异步 | VIDEO_COVER_LIST 同步 | LOCAL_LIVE_DATA 本地常量 filter |
| 列表布局 | List + LazyForEach | Grid + LazyForEach | List + LazyForEach |
| 子页面数 | 2 个 NavDestination | 1 个 NavDestination | 0 |
| @Consumer | 是 | 是 | 否 |
| Tab 实现 | Tabs + @Builder | Tabs + @Builder | 自定义 Row + Text |
| CommonDataSource | 是 | 是 | 是 |
设计启示:
- news是最复杂的模块(4组件+2页面+异步数据+5个@Builder),适合作为核心功能
- video中等复杂度,引入了 Grid 布局和 Video 组件
- live最精简,但展示了 LazyForEach + 单一数据源切换和自定义 Tab 栏的最佳实践
八、V2 装饰器使用汇总
| 装饰器 | 变量/位置 | 作用 |
|---|---|---|
@ComponentV2 | LiveHome | V2 组件声明 |
@Local | currentIndex、dataSource | 组件内状态 + 数据源 |
注意直播模块是唯一不使用 @Consumer 的 feature 模块,因为没有子页面跳转需求。
九、常见问题 Q&A
Q1: 为什么从 LIVE_DATA 改为 LOCAL_LIVE_DATA?
A: 实际调试发现,从 common 模块导入的 @ObservedV2 对象数组LIVE_DATA在 feature 模块运行时无法正常使用(filter 返回空数组)。这是因为 @ObservedV2 代理对象跨模块传递时存在问题。因此改为在 live 模块内本地定义数据。
Q2: 为什么用 LazyForEach 而非 ForEach?
A: 虽然数据量只有 12 条,但使用 CommonDataSource + LazyForEach 与 news/video 保持一致的模式,便于统一理解和维护。同时 LazyForEach 的数据源切换(setData)比替换数组(ForEach)更可靠。
Q3: 为什么不用嵌套 Tabs?
A: MainPage 外层已有 Tabs(首页/视频/直播/我的),在 LiveHome 内嵌套 Tabs 会导致布局冲突,TabContent 内容无法渲染。自定义 Row + Text 实现同样效果。
Q4: "精彩回放"分类为什么没有对应 Tab?
A: LOCAL_LIVE_DATA 包含 4 个分类各 3 条,但 LiveHome 只定义了 3 个 Tab。第 4 个分类"精彩回放"的数据被 filter 后未使用。实际项目中可以添加第 4 个 Tab。
Q5: 如何添加直播详情子页面?
A: 需要以下步骤:
- 在
pages/下创建LiveDetail.ets,使用NavDestination+.hideTitleBar(true) - 添加
@Param pageStack到LiveHome,并在 MainPage.pageMap 中显式传参 - 在卡片的
onClick中调用AppStorage.setOrCreate存储纯对象数据 +pushPathByName - 在
MainPage的pageMap中注册'LiveDetail'路由 - 更新
Index.ets导出LiveDetail
注意:不要直接传递 @ObservedV2 对象给 AppStorage,必须提取为纯
Record<string, Object>后再存储。
十、小结
直播模块以 112 行源码实现了完整的直播内容浏览功能,核心知识点包括:
- LOCAL_LIVE_DATA 本地数据:避免跨模块 @ObservedV2 对象数组运行时不可用的问题
- 单一 CommonDataSource + setData:Tab 切换时更新数据源,触发 LazyForEach 重绘
- 自定义 Tab 栏:用 Row + Text 替代嵌套 Tabs,避免布局冲突
- LazyForEach + CommonDataSource:与 news/video 保持一致的数据加载模式
- 最精简 feature 模块:无子页面、无 @Consumer,是学习 feature 模块结构的最佳入门示例
