《新闻资讯》四、视频模块实现指南

HarmonyOS 新闻资讯应用 · 视频模块 features/video 实现指南

开发环境 :DevEco Studio 6.1.0 Release

SDK版本 :HarmonyOS SDK 6.1.0(23) / API 23

开发语言 :ArkTS

状态管理 :V2(@ComponentV2系列装饰器)

前置阅读common模块指南 · 整体架构指南

本篇详细讲解视频模块 features/video 的实现。作为应用第二大 Tab 页,视频模块以 Grid 双列网格 布局展示视频封面,通过 Tabs 组件 实现 5 个分类切换,点击封面进入 Video 组件播放页。整个模块仅有 2 个组件、3 个源文件、212 行源码,是结构清晰、易于理解的 HAR 特性模块。


效果


一、模块定位与架构角色

视频模块在三层架构中处于基础特性层(features),编译为 HAR 包,依赖 common 公共能力层:

复制代码
产品定制层 product/phone (HAP)
    ↓ 依赖
基础特性层 features/news | features/video ← 本模块 | features/live | features/personal | features/service (HAR)
    ↓ 依赖
公共能力层 common (HAR)

模块职责:提供视频浏览和播放功能,包含 2 个组件:

组件 文件 行数 类型 功能
VideoHome components/VideoHome.ets 112 @ComponentV2 视频主页,Grid 双列封面列表 + Tabs 5分类
VideoPlayer pages/VideoPlayer.ets 97 @ComponentV2 视频播放页,NavDestination + Video 组件

被依赖关系product/phoneMainPageVideoHome 嵌入第二个 HdsTab,用户在主页切换到"视频"Tab 时展示。VideoPlayer 作为 NavDestination 页面,由 MainPagepageMap 路由注册。


二、模块配置详解

2.1 oh-package.json5

json5 复制代码
{
  "name": "video",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "Index.ets",
  "author": "",
  "license": "Apache-2.0",
  "dependencies": {
    "common": "file:../../common"
  }
}

逐字段讲解

字段 说明
name "video" 包标识符,import ... from 'video' 时使用
main "Index.ets" 入口文件,外部导入时自动解析到此文件
dependencies.common "file:../../common" 本地路径依赖,从 features/video 向上两级到 common

与 news 模块配置完全一致,仅 name 不同。所有 features 模块都依赖 common,路径格式统一为 "file:../../common"

2.2 Index.ets 入口文件

typescript 复制代码
export { VideoHome } from './src/main/ets/components/VideoHome';
export { VideoPlayer } from './src/main/ets/pages/VideoPlayer';

仅 3 行代码,导出 2 个组件。外部通过 import { VideoHome, VideoPlayer } from 'video' 即可使用。注意 VideoPlayer 也在此导出,虽然它是由 MainPagepageMap 内部使用,不需要外部直接实例化。


三、完整文件结构树

复制代码
features/video/
├── oh-package.json5                    (12行) 模块配置
├── Index.ets                            (3行)  统一导出
├── src/main/
│   ├── ets/
│   │   ├── components/
│   │   │   └── VideoHome.ets            (112行) 视频主页,Grid+Tabs
│   │   └── pages/
│   │       └── VideoPlayer.ets          (97行)  视频播放页,Video组件
│   └── resources/                       (图片、视频等静态资源)

目录职责

  • components/ --- 可复用的页面组件,VideoHome 是嵌入 HdsTab 的主组件
  • pages/ --- 通过 NavDestination 展示的二级页面,VideoPlayer 由路由跳转进入

四、VideoHome 视频主页

VideoHome 是视频模块的核心组件,展示 5 个分类 Tab 和双列视频封面网格。

4.1 完整源码

typescript 复制代码
import { VideoItem, CommonDataSource, CommonConstants, StyleConstants } from 'common';

/**
 * 视频主页组件
 * 使用 @ComponentV2 + @Local 管理状态
 * 展示视频分类Tab和视频Grid列表
 */
@ComponentV2
export struct VideoHome {
  @Local videoList: CommonDataSource<VideoItem> = new CommonDataSource<VideoItem>();
  @Local currentIndex: number = 0;
  @Consumer('pageStack') pageStack!: NavPathStack;
  private categories: string[] = ['推荐', '热门', '科技', '生活', '娱乐'];

  aboutToAppear() {
    let items: VideoItem[] = [];
    let covers = CommonConstants.VIDEO_COVER_LIST;
    for (let i = 0; i < covers.length; i++) {
      items.push(new VideoItem(
        `video_${i}`,
        `精彩视频 ${i + 1}`,
        covers[i],
        'videoTest.mp4',
        this.categories[i % this.categories.length]
      ));
    }
    this.videoList.setData(items);
  }

  @Builder
  TabLabel(label: string, index: number) {
    Column() {
      Text(label)
        .fontSize(14)
        .fontColor(this.currentIndex === index ? '#E84026' : StyleConstants.TEXT_PRIMARY)
        .fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
      if (this.currentIndex === index) {
        Divider()
          .width(20)
          .strokeWidth(2)
          .color('#E84026')
          .margin({ top: 4 })
      }
    }
    .width(60)
    .height(40)
    .justifyContent(FlexAlign.Center)
  }

  build() {
    Column() {
      // 顶部标题
      Text('视频')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(StyleConstants.TEXT_PRIMARY)
        .margin({ left: 16, top: 12, bottom: 8 })
        .width('100%')

      // 分类Tab
      Tabs({ barPosition: BarPosition.Start, index: this.currentIndex }) {
        ForEach(this.categories, (item: string, index?: number) => {
          TabContent() {
            Grid() {
              LazyForEach(this.videoList, (video: VideoItem) => {
                GridItem() {
                  Column() {
                    Stack() {
                      Image(video.coverImage)
                        .width('100%')
                        .height(100)
                        .borderRadius(8)
                        .objectFit(ImageFit.Cover)

                      // 播放图标
                      Image($r('app.media.play_round_rectangle_fill'))
                        .width(32)
                        .height(32)
                    }

                    Text(video.title)
                      .fontSize(13)
                      .fontColor(StyleConstants.TEXT_PRIMARY)
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                      .margin({ top: 6 })
                  }
                  .onClick(() => {
                    this.pageStack.pushPathByName('VideoPlayer', video);
                  })
                }
              }, (item: VideoItem) => item.videoId)
            }
            .columnsTemplate('1fr 1fr')
            .rowsGap(16)
            .columnsGap(12)
            .padding({ left: 16, right: 16 })
          }
          .tabBar(this.TabLabel(item, index ?? 0))
        }, (item: string) => item)
      }
      .onChange((index: number) => {
        this.currentIndex = index;
      })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
  }
}

4.2 分段深度讲解

段落1 --- 导入与状态变量(第1-13行)

导入语句 :从 common 导入 4 个内容:

导入项 类型 用途
VideoItem 数据模型 视频数据项,@ObservedV2 类
CommonDataSource 泛型数据源 IDataSource 实现,配合 LazyForEach
CommonConstants 常量类 提供 VIDEO_COVER_LIST(20 张封面 Resource)
StyleConstants 样式常量 颜色、圆角等统一样式

状态变量声明

装饰器 变量 类型 说明
@Local videoList CommonDataSource<VideoItem> 视频数据源,泛型为 VideoItem
@Local currentIndex number 当前选中的 Tab 索引,初始为 0
@Consumer('pageStack') pageStack NavPathStack 从 MainPage 的 @Provider 注入,用于路由跳转
无(private) categories string[] 5 个分类标签:推荐/热门/科技/生活/娱乐

@Consumer('pageStack') 原理回顾 :MainPage 通过 @Provider('pageStack') 将 NavPathStack 注入组件树,所有子组件通过 @Consumer('pageStack') 按 key 匹配获取同一实例。! 是非空断言,表示运行时一定由父组件注入。

段落2 --- aboutToAppear 数据初始化(第15-28行)
typescript 复制代码
aboutToAppear() {
  let items: VideoItem[] = [];
  let covers = CommonConstants.VIDEO_COVER_LIST;  // 20张封面Resource数组
  for (let i = 0; i < covers.length; i++) {
    items.push(new VideoItem(
      `video_${i}`,                              // videoId:唯一标识
      `精彩视频 ${i + 1}`,                        // title:标题
      covers[i],                                  // coverImage:封面Resource
      'videoTest.mp4',                            // videoSrc:视频路径
      this.categories[i % this.categories.length] // category:按余数循环分配
    ));
  }
  this.videoList.setData(items);                  // 触发 notifyDataReloaded
}

数据生成逻辑

  1. CommonConstants.VIDEO_COVER_LIST 获取 20 张封面图片资源($r('app.media.video_01')$r('app.media.video_20')
  2. 循环 20 次,每次创建一个 VideoItem
  3. category 使用取余算法 i % 5,确保 5 个分类均匀分布:0→推荐、1→热门、2→科技、3→生活、4→娱乐、5→推荐...
  4. 所有视频共用同一个测试文件 videoTest.mp4(实际项目中会存储不同视频路径)
  5. setData(items) 调用后触发 notifyDataReloaded(),LazyForEach 重新加载数据

与 news 模块数据加载对比

对比项 news(NewsHome) video(VideoHome)
数据来源 rawfile JSON(异步加载) VIDEO_COVER_LIST 常量(同步构建)
加载方式 MockDataUtil.getNewsList() 循环构造器直接创建
异步处理 async/await + context 同步,无需异步
数据量 由 JSON 文件决定 固定 20 条
段落3 --- @Builder TabLabel 自定义标签(第30-48行)
typescript 复制代码
@Builder
TabLabel(label: string, index: number) {
  Column() {
    Text(label)
      .fontSize(14)
      .fontColor(this.currentIndex === index ? '#E84026' : StyleConstants.TEXT_PRIMARY)
      .fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
    if (this.currentIndex === index) {
      Divider()
        .width(20)
        .strokeWidth(2)
        .color('#E84026')
        .margin({ top: 4 })
    }
  }
  .width(60)
  .height(40)
  .justifyContent(FlexAlign.Center)
}

@Builder 是什么 :@Builder 是 ArkUI 提供的 UI 构建模板装饰器,可以在 build() 中通过 this.TabLabel(...) 调用,编译时内联到调用处。与独立组件不同,@Builder 不需要单独声明 @ComponentV2,适合复用简单的 UI 片段。

TabLabel 选中态逻辑

状态 文字颜色 字重 下划线
选中(currentIndex === index #E84026(红色) Bold 红色 Divider(宽20、粗2)
未选中 TEXT_PRIMARY(主色) Normal 无(if 条件不满足)

条件渲染 if :在 ArkUI 中,if 语句可以直接控制组件的创建和销毁。当条件从 false 变为 true 时创建 Divider 组件,反之销毁。这比设置 .opacity(0) 更高效,因为未选中的 Tab 不会创建多余的组件。

调用方式 :在第 99 行通过 .tabBar(this.TabLabel(item, index ?? 0)) 调用,index ?? 0 使用空值合并运算符确保 index 为 number 类型(ForEach 的 index 参数类型为 number | undefined)。

段落4 --- build() 主布局(第50-110行)

布局层级结构

复制代码
Column (根容器)
├── Text('视频')                        ← 顶部标题,20号加粗
└── Tabs (barPosition:Start)            ← 分类Tab,标签栏在顶部
    └── ForEach(categories) → TabContent
        └── Grid ('1fr 1fr')            ← 双列网格
            └── LazyForEach → GridItem
                └── Column
                    ├── Stack
                    │   ├── Image(coverImage)    ← 封面图,100高
                    │   └── Image(播放图标)       ← 32×32叠加
                    └── Text(title)              ← 标题,单行截断

Grid 双列布局详解

typescript 复制代码
Grid() {
  LazyForEach(this.videoList, (video: VideoItem) => {
    GridItem() { ... }
  }, (item: VideoItem) => item.videoId)
}
.columnsTemplate('1fr 1fr')   // 两列等宽,1fr = 1 fraction(等分剩余空间)
.rowsGap(16)                   // 行间距 16vp
.columnsGap(12)                // 列间距 12vp
.padding({ left: 16, right: 16 })
Grid 属性 说明
columnsTemplate '1fr 1fr' 两列等宽。1fr 表示一份可用空间,两列各占 50%
rowsGap 16 行间距 16vp,视觉分隔
columnsGap 12 列间距 12vp,略小于行间距
padding left/right: 16 左右内边距,与页面标题对齐

Stack 封面叠加设计

typescript 复制代码
Stack() {
  Image(video.coverImage)
    .width('100%')
    .height(100)
    .borderRadius(8)
    .objectFit(ImageFit.Cover)        // 等比裁切填满

  Image($r('app.media.play_round_rectangle_fill'))
    .width(32)
    .height(32)                        // 播放图标居中叠加
}

Stack 默认居中对齐子组件,所以播放图标自动叠加在封面图中央。ImageFit.Cover 确保封面图等比缩放裁切填满容器,避免拉伸变形。

路由跳转

typescript 复制代码
.onClick(() => {
  this.pageStack.pushPathByName('VideoPlayer', video);
})

点击视频卡片后,将 VideoItem 作为路由参数推入 NavPathStackMainPagepageMap 匹配 'VideoPlayer' 路由名,渲染 VideoPlayer 组件并传递参数。

Tabs 配置

typescript 复制代码
Tabs({ barPosition: BarPosition.Start, index: this.currentIndex })
  .onChange((index: number) => {
    this.currentIndex = index;  // 更新选中索引,触发 TabLabel 重新渲染
  })
  .layoutWeight(1)              // 占满剩余高度(减去顶部标题)

BarPosition.Start 表示标签栏在顶部(默认行为),index 双向绑定当前选中 Tab。.onChange 在用户滑动或点击切换时触发,更新 currentIndex 使 TabLabel 的选中态正确切换。

ForEach 与 .tabBar 的配合

typescript 复制代码
ForEach(this.categories, (item: string, index?: number) => {
  TabContent() {
    // 每个Tab的内容:Grid列表
  }
  .tabBar(this.TabLabel(item, index ?? 0))  // 自定义标签
}, (item: string) => item)  // key生成器:用分类名作唯一key

ForEach 遍历 5 个分类,每个创建一个 TabContent.tabBar() 接收 @Builder 返回值,替换默认的 Tab 标签样式。注意每个 TabContent 都包含完整的 Grid 列表------这意味着切换 Tab 时,同一个 videoList 数据源在所有 Tab 中展示相同内容(实际项目中可根据分类过滤)。


五、VideoPlayer 视频播放页

VideoPlayer 是从 VideoHome 点击跳转后的二级页面,通过 NavDestination 嵌入 Navigation 容器,使用 Video 组件播放视频。

5.1 完整源码

typescript 复制代码
import { VideoItem, StyleConstants } from 'common';

/**
 * 视频播放页
 * 使用 @ComponentV2 + NavDestination 实现
 * 支持视频播放、进度控制
 */
@ComponentV2
export struct VideoPlayer {
  @Param pageStack: NavPathStack = new NavPathStack();  // @Param 替代 @Consumer
  @Local videoItem: VideoItem = new VideoItem('', '视频播放', $r('app.media.preview'));
  controller: VideoController = new VideoController();

  aboutToAppear() {
    let param: Record<string, Object> | undefined =
      AppStorage.get<Record<string, Object>>('routeParam_VideoPlayer');  // AppStorage 纯对象
    if (param) {
      this.videoItem = new VideoItem(
        (param['videoId'] as string) ?? '',
        (param['title'] as string) ?? '',
        (param['coverImage'] as Resource) ?? $r('app.media.preview'),
        (param['videoSrc'] as string) ?? '',
        (param['category'] as string) ?? '推荐'
      );
    }
  }

  build() {
    NavDestination() {
      Column() {
        // 顶部返回栏
        Row() {
          Image($r('app.media.back'))
            .width(24)
            .height(24)
            .margin({ left: 16 })
            .onClick(() => {
              this.pageStack.pop();
            })

          Text(this.videoItem.title)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .margin({ right: 40 })
        }
        .width('100%')
        .height(56)
        .padding({ top: 8 })

        // 视频播放器
        Video({
          src: $rawfile('videoTest.mp4'),
          controller: this.controller,
          previewUri: this.videoItem.coverImage
        })
          .width('100%')
          .aspectRatio(16 / 9)
          .autoPlay(false)
          .controls(true)
          .objectFit(ImageFit.Contain)

        // 视频信息
        Column() {
          Text(this.videoItem.title)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor(StyleConstants.TEXT_PRIMARY)
            .width('100%')
            .margin({ top: 12 })

          Row({ space: 16 }) {
            Text(`分类:${this.videoItem.category}`)
              .fontSize(13)
              .fontColor(StyleConstants.TEXT_SECONDARY)
            Text('华为日报')
              .fontSize(13)
              .fontColor(StyleConstants.TEXT_SECONDARY)
          }
          .margin({ top: 8 })
          .width('100%')

          Text('精彩视频内容,带你领略不一样的世界。本视频展示了最新的技术发展和应用场景。')
            .fontSize(14)
            .lineHeight(24)
            .fontColor(StyleConstants.TEXT_SECONDARY)
            .width('100%')
            .margin({ top: 12 })
        }
        .padding({ left: 16, right: 16 })

        Blank()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(Color.White)
    }
    .hideTitleBar(true)
  }
}

5.2 分段深度讲解

段落1 --- 状态变量与控制器(第9-12行)
typescript 复制代码
@Consumer('pageStack') pageStack!: NavPathStack;
@Local videoItem: VideoItem = new VideoItem('', '视频播放', $r('app.media.preview'));
controller: VideoController = new VideoController();
变量 装饰器 类型 说明
pageStack @Consumer('pageStack') NavPathStack 路由栈,用于 getParamByNamepop
videoItem @Local VideoItem 当前播放的视频数据,默认值用 preview 封面
controller 无(普通属性) VideoController 视频控制器,可控制播放/暂停/跳转

VideoController 用途VideoController 是 ArkUI 提供的视频控制器类,支持以下方法:

  • start() --- 开始播放
  • pause() --- 暂停播放
  • stop() --- 停止播放
  • seekTo(timeInSeconds) --- 跳转到指定时间
  • requestFullscreen() / exitFullscreen() --- 全屏控制

本例中 controller 仅声明并传递给 Video 组件,未主动调用方法。保留 controller 是为了后续扩展(如自定义控制按钮)。

段落2 --- aboutToAppear 路由参数获取(第14-19行)
typescript 复制代码
aboutToAppear() {
  let param = this.pageStack.getParamByName('VideoPlayer');
  if (param) {
    this.videoItem = param as VideoItem;
  }
}

参数传递链路

复制代码
VideoHome.onClick()
  → pushPathByName('VideoPlayer', video)     // 推入路由栈,video 作为参数
  → Navigation 匹配 'VideoPlayer' 路由
  → VideoPlayer 组件创建
  → aboutToAppear()
  → getParamByName('VideoPlayer')            // 按路由名取出参数
  → param as VideoItem                       // 类型断言

getParamByName 从 NavPathStack 中获取当前路由名对应的参数。as VideoItem 是 TypeScript 类型断言,告诉编译器参数一定是 VideoItem 类型。

段落3 --- Video 组件详解(第48-57行)
typescript 复制代码
Video({
  src: $rawfile('videoTest.mp4'),    // 视频源:rawfile 中的视频文件
  controller: this.controller,       // 控制器:控制播放/暂停/跳转
  previewUri: this.videoItem.coverImage  // 封面预览:播放前显示的图片
})
  .width('100%')
  .aspectRatio(16 / 9)               // 16:9 宽高比
  .autoPlay(false)                    // 不自动播放,用户需点击开始
  .controls(true)                     // 显示系统控制栏(播放/暂停/进度条/全屏)
  .objectFit(ImageFit.Contain)        // 等比缩放,完整显示不裁切

Video 组件属性详解

属性 类型 说明
src Resource $rawfile('videoTest.mp4') 视频文件路径,从 rawfile 资源目录加载
controller VideoController this.controller 播放控制器实例
previewUri Resource coverImage 视频未播放时的封面图
autoPlay boolean false 加载后不自动播放
controls boolean true 显示播放控制栏
objectFit ImageFit Contain 等比缩放完整显示(对比 Cover 会裁切)
aspectRatio number 16/9 ≈ 1.78 宽高比,自动根据宽度计算高度

ImageFit.Contain vs ImageFit.Cover

模式 效果 适用场景
Contain 等比缩放,完整显示,可能留黑边 视频播放(不裁切内容)
Cover 等比缩放,填满容器,可能裁切边缘 封面图(填满卡片)
段落4 --- 顶部返回栏(第25-42行)
typescript 复制代码
Row() {
  Image($r('app.media.back'))          // 返回图标
    .width(24).height(24)
    .margin({ left: 16 })
    .onClick(() => { this.pageStack.pop(); })  // 返回上一页

  Text(this.videoItem.title)           // 标题
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .layoutWeight(1)                   // 占满剩余宽度
    .textAlign(TextAlign.Center)       // 居中
    .maxLines(1)                       // 单行
    .textOverflow({ overflow: TextOverflow.Ellipsis })  // 超长截断...
    .margin({ right: 40 })             // 右侧留白与返回按钮对称
}
.width('100%').height(56).padding({ top: 8 })

布局技巧 :返回图标在左(固定24宽),标题居中(layoutWeight:1 占满中间),margin({ right: 40 }) 为标题右侧留白,使标题在视觉上居中(因为左侧有24+16=40宽的返回按钮区域)。

段落5 --- 视频信息区(第60-86行)
typescript 复制代码
Column() {
  Text(this.videoItem.title)               // 视频标题
    .fontSize(16).fontWeight(FontWeight.Medium)

  Row({ space: 16 }) {                     // 分类 + 来源
    Text(`分类:${this.videoItem.category}`)  // 模板字符串
    Text('华为日报')
  }

  Text('精彩视频内容...')                    // 描述文字
    .fontSize(14).lineHeight(24)            // 行高24,增强可读性
}
.padding({ left: 16, right: 16 })

模板字符串分类:${this.videoItem.category} 使用 ArkTS 模板字符串语法,将变量值嵌入字符串中。如果 category 是"推荐",则显示"分类:推荐"。

Blank() 占位 :在信息区后面放置 Blank() 组件,自动填充剩余垂直空间,将内容推到顶部,避免底部空白区域显示异常。

typescript 复制代码
NavDestination() {
  // ... 页面内容
}
.hideTitleBar(true)  // 隐藏系统默认标题栏,使用自定义顶部栏

与 news 模块的 NewsDetail 一致,.hideTitleBar(true) 隐藏系统默认导航栏,使用自定义的顶部返回栏。NavDestination 是 Navigation 容器的子页面组件,必须通过 NavPathStack 路由跳转进入。


六、Grid 布局 vs List 布局对比

本项目中,news 和 live 模块使用 List 布局,video 模块使用 Grid 布局。以下是两者的详细对比:

特性 Grid 网格布局 List 列表布局
列数控制 columnsTemplate('1fr 1fr') 灵活定义 固定单列
子项组件 GridItem ListItem
行间距 rowsGap space
列间距 columnsGap 不适用
适用场景 图片/视频网格、相册、商品列表 新闻列表、聊天记录、直播列表
性能优化 配合 LazyForEach 配合 LazyForEach
本项目使用 VideoHome(双列视频封面) NewsHomeLiveHome

何时选择 Grid:当内容以图片/卡片为主、需要多列展示时,Grid 是最佳选择。视频封面、商品图片、相册浏览等场景天然适合网格布局。

何时选择 List:当内容以文字为主、单列纵向排列时,List 更合适。新闻标题列表、聊天记录、设置项等场景使用 List 更自然。


七、V2 装饰器使用汇总

装饰器 变量/位置 作用
@ComponentV2 VideoHomeVideoPlayer V2 组件声明,替代 @Component
@Local videoListcurrentIndexvideoItem 组件内状态,替代 @State
@Consumer('pageStack') VideoHome.pageStack @Provider 消费 NavPathStack
@Param VideoPlayer.pageStack MainPage 显式传参
@Builder TabLabel 可复用 UI 构建模板

与 news 模块装饰器对比

装饰器 news 模块 video 模块
@ComponentV2 ✅ 4个组件 ✅ 2个组件
@Local
@Param ✅ NewsListItem ❌ 无子组件传参
@Consumer
@Builder ✅ 5个 ✅ 1个

八、关键设计模式总结

8.1 Grid + LazyForEach 性能优化

Grid 配合 LazyForEach 实现按需加载,只有进入可视区域的 GridItem 才会被创建。当列表很长时(20条以上),相比 ForEach 一次性创建所有子组件,性能优势显著。

8.2 Tabs + ForEach 动态生成

通过 ForEach 遍历 categories 数组动态生成 TabContent,配合 .tabBar(this.TabLabel(...)) 自定义标签样式。这种模式让 Tab 数量和内容完全由数据驱动,增删分类只需修改数组。

8.3 Stack 叠加图标

使用 Stack 将播放图标叠加在封面图上,比使用 backgroundImage 更灵活------可以自由控制图标大小、位置、动画效果。

8.4 路由参数传递

复制代码
pushPathByName('VideoPlayer', video)   →   AppStorage.get('routeParam_VideoPlayer')

重要变更 :原方案使用 getParamByName 获取路由参数,但 @Param NavPathStack 可能创建浅拷贝导致参数读取失败。现方案改为 AppStorage 纯对象中转 :VideoHome 在 onClick 中提取纯 Record<string, Object> 存入 AppStorage,VideoPlayer 在 aboutToAppear 中读取并重建。

注意:AppStorage 禁止存储 @ObservedV2 代理对象(错误码 140115)。


九、常见问题 Q&A

Q1: 为什么所有 Tab 展示相同的视频列表?

A: 当前实现中所有 5 个 TabContent 共用同一个 videoList 数据源,所以展示相同内容。实际项目中,可以在 onChange 中根据 currentIndex 切换不同数据源,或对 videoList 进行 filter 过滤。

Q2: Grid 的 columnsTemplate('1fr 1fr') 如何改为三列?

A: 改为 '1fr 1fr 1fr' 即可。也可以用固定宽度如 '120vp 120vp 120vp',或混合使用 '1fr 2fr'(第二列是第一列两倍宽)。

Q3: 为什么 videoItem 使用 @Local 而非 @Param

A: @Param 用于父组件向子组件传递数据,而 VideoPlayer 的数据来自 NavPathStack 路由参数(在 aboutToAppear 中获取),不通过父组件直接传递,所以使用 @Local

Q4: 如何支持全屏播放?

A: 通过 controller.requestFullscreen() 进入全屏。可以添加一个全屏按钮,onClick 时调用此方法。Video 组件的 controls(true) 已包含全屏按钮。

Q5: previewUri 何时显示?

A: 在视频开始播放前(autoPlay(false) 时)显示封面图。用户点击播放后,封面图自动被视频画面替换。


十、小结

视频模块以 212 行源码实现了完整的视频浏览和播放功能,核心知识点包括:

  1. Grid 双列布局columnsTemplate('1fr 1fr') + rowsGap/columnsGap 构建网格
  2. Stack 叠加设计:封面图上叠加播放图标
  3. Video 组件src + controller + previewUri + controls + aspectRatio 实现视频播放
  4. Tabs + ForEach:数据驱动的动态 Tab 生成
  5. AppStorage + NavPathStack 路由传参:纯对象存入 AppStorage,避免 @ObservedV2 代理对象存储问题

相关推荐
风华圆舞1 小时前
鸿蒙 + Flutter 下如何让 HarmonyOS 能力真正服务于 AI 体验
人工智能·flutter·harmonyos
Swift社区1 小时前
鸿蒙游戏为什么掉帧?60FPS性能优化实战指南
游戏·性能优化·harmonyos
为何创造硅基生物2 小时前
LVGL
c++·ui
lqj_本人2 小时前
AnotherRedisDesktopManager鸿蒙适配全记录
华为·harmonyos
YM52e2 小时前
鸿蒙PC ArkTS 异常处理深度解析与最佳实践
学习·华为·harmonyos
xcLeigh2 小时前
鸿蒙PC平台 Shotwell 照片管理器适配实战:从 Linux GNOME 到 鸿蒙PC 的 Electron 迁移
linux·electron·harmonyos·鸿蒙·shotwell·照片管理器
yuegu7772 小时前
HarmonyOS应用<节气通>开发第28篇:工具类封装
harmonyos
伶俜662 小时前
鸿蒙原生应用实战(七)ArkUI 文件管理器:目录浏览 + 文件操作 + 搜索筛选
学习·华为·harmonyos
大雷神2 小时前
第96篇 | HarmonyOS 异常合集:权限拒绝、网络失败、模型失败、相机失败
harmonyos