HarmonyOS 6.1 全场景实战|灵犀厨房 | 多媒体 | AVPlayer嵌入教学视频------让智慧屏真正"活"起来
摘要 :在《分布式流转让菜谱"飞":手机选、平板看、智慧屏播的全场景秘诀》篇章中,我们实现了「手机选菜谱→平板看步骤→智慧屏播视频」的分布式流转,但智慧屏上的视频播放还是静态的「模拟区」------点击只会显示一个 LoadingProgress,没有实际的视频内容。本篇将利用 HarmonyOS 6.1.0 的 AVPlayer(@kit.MediaKit),替换模拟区域为真实的视频播放器,让流转到智慧屏后能真正播放烹饪教学视频,把《灵犀厨房》的全场景体验推上一个新台阶。
一、为什么需要真实 AVPlayer?
回顾《分布式流转让菜谱"飞":手机选、平板看、智慧屏播的全场景秘诀》的智慧屏布局:
typescript
// 旧版:模拟视频区域 ------ 只有 LoadingProgress,不是真播放
if (this.isVideoPlaying) {
Column({ space: 12 }) {
LoadingProgress().width(48).height(48).color('#FF6B35')
Text('正在播放烹饪教学...').fontSize(18)
}
}
这个「模拟」实现有三个致命问题:
| 问题 | 影响 | 用户感知 |
|---|---|---|
| 不是真实播放 | 没有视频画面,只有一个转圈动画 | "这视频怎么一直在加载?" |
| 没有播放控制 | 无法暂停、重播、查看进度 | "我想倒回去看重播怎么办?" |
| 无法接入真实内容 | 后端返回的视频 URL 无法被消费 | "难道我只能看这个假的?" |
🎯 本篇目标 :用 HarmonyOS 的
AVPlayer + XComponent替换模拟区,实现完整的视频播放能力------加载→播放→暂停→重播→错误处理。用户流转到智慧屏后,看到的不再是一个假的加载动画,而是真正的烹饪教学视频画面。
二、核心原理:AVPlayer 与 XComponent 的"双引擎"协作
2.1 AVPlayer 在 HarmonyOS 架构中的位置

图一解读 :AVPlayer 本身是**无头(headless)**的播放器------它负责解码和音画同步,但不提供 UI 渲染容器。XComponent 组件提供了一个 SURFACE 类型的原生渲染画布,AVPlayer 将解码后的视频帧渲染到这个画布上。两者通过 surfaceId 建立连接------就像放映机(AVPlayer)需要银幕(XComponent)才能让观众看到画面。
2.2 AVPlayer 状态机

图二解读 :AVPlayer 的状态机看似复杂,但核心路径只有一条:idle → initialized → prepared → playing。其他状态都是从这条主路径分支出来的。理解这个状态机是处理各种边界情况的关键------比如播放完成后要重播,不能直接 play(),必须先 seek(0) 回到开头;释放播放器前必须先解绑事件监听,否则可能内存泄漏。
与 Web 的 <video> 标签不同,HarmonyOS 的 AVPlayer 不给开发者提供"自动播放"选项------你必须先调用 prepare(),等它进入 prepared 状态后,再手动调用 play()。这是一种"防御性设计":避免页面一加载就自动播放视频、消耗用户流量的糟糕体验。
2.3 异步时序问题:谁先就绪?
AVPlayer 创建和 XComponent 就绪是两条独立的异步路径,谁先完成不确定:
时间线 A: AVPlayer 创建完成 ──→ 等待 surface ──→ 收到 surfaceId ──→ bind + prepare
时间线 B: XComponent.onLoad ──→ 获取 surfaceId ──→ 等待 AVPlayer ──→ bind + prepare
这就像两个快递员------你不知道谁会先到。如果 AVPlayer 先到,它需要等 XComponent 准备好 surface;如果 XComponent 先到,它需要等 AVPlayer 创建完成。
解决方案 :bindSurfaceAndPrepare() 方法在两条路径的末端均被调用,互补确保绑定一定发生:
typescript
// 路径 A:initAVPlayer() 末尾
private async initAVPlayer(): Promise<void> {
this.avPlayer = await media.createAVPlayer();
this.avPlayer.url = videoUrl;
// ... 注册回调 ...
this.bindSurfaceAndPrepare(); // ← 若 surface 已就绪,立即 bind + prepare
}
// 路径 B:XComponent.onLoad
XComponent({ ... })
.onLoad(() => {
this.videoSurfaceId = this.xComponentController.getXComponentSurfaceId();
this.bindSurfaceAndPrepare(); // ← 若 AVPlayer 已创建,立即 bind + prepare
})
bindSurfaceAndPrepare() 内部做了双重守卫:
typescript
private bindSurfaceAndPrepare(): void {
if (!this.avPlayer || this.videoSurfaceId.length === 0) return; // 任一条件不满足→退出
if (this.avPlayerState === VideoPlayerState.LOADING) { // 仅 LOADING 状态执行
this.avPlayer.surfaceId = this.videoSurfaceId;
this.avPlayer.prepare();
}
}
这种设计不关心"谁先到",只关心"两个都到了"。无论异步时序如何,只要 AVPlayer 和 surfaceId 都就绪,绑定就会发生------且只发生一次。
三、数据模型变更:添加 videoUrl
在替换模拟播放器之前,需要先让菜谱数据支持视频 URL。
3.1 Recipe 接口扩展
typescript
// foundation/model/Recipe.ets(entry + shared 同步修改)
export interface Recipe {
// ... 现有字段 ...
/** 教学视频 URL(流转到智慧屏时播放) */
videoUrl?: string;
}
videoUrl 设为可选(?:),原因:
- 并非每道菜谱都有视频------只有标记了「视频教学」的菜谱才配置
- 向前兼容:已有菜谱不需要修改即可编译通过
- 智慧屏 UI 已处理「暂无视频」的空状态展示
3.2 MockData 补充
为 10 道菜谱统一配置测试视频 URL:
typescript
{
id: 1, name: '番茄牛腩煲',
// ... 其他字段 ...
videoUrl: 'https://media.w3.org/2010/05/sintel/trailer.mp4'
}
说明 :测试 URL 使用的是 W3C 提供的 Sintel 预告片(公有领域),适合开发调试。生产环境中,
videoUrl应由后端 API 返回每道菜谱的真实教学视频地址。
四、代码实现详解
4.1 状态枚举
typescript
enum VideoPlayerState {
IDLE = 'idle', // 未初始化 / 已释放
LOADING = 'loading', // 创建中 / 等待 surface
READY = 'ready', // prepare 完成,可播放
PLAYING = 'playing', // 播放中
PAUSED = 'paused', // 已暂停
COMPLETED = 'completed', // 播放完成
ERROR = 'error' // 错误
}
设计决策 :为什么用字符串枚举而非数字?因为字符串在日志中可读------当你看到
[AVPlayer] 状态变更: idle → playing,比0 → 3直观得多。这在调试异步播放问题时尤其重要。
4.2 initAVPlayer() ------ 播放器初始化
typescript
private async initAVPlayer(): Promise<void> {
this.releaseAVPlayer(); // ← 先释放旧实例(幂等)
const videoUrl = this.recipe.videoUrl ?? DEFAULT_VIDEO_URL;
if (!videoUrl) {
this.avPlayerState = VideoPlayerState.IDLE; // 无视频→保持空闲
return;
}
this.avPlayerState = VideoPlayerState.LOADING;
try {
this.avPlayer = await media.createAVPlayer();
this.avPlayer.url = videoUrl;
// 注册五大事件回调
this.avPlayer.on('stateChange', (state) => this.handleAVPlayerStateChange(state));
this.avPlayer.on('timeUpdate', (time) => { this.videoCurrentTime = time; });
this.avPlayer.on('durationUpdate', (duration) => { this.videoDuration = duration; });
this.avPlayer.on('error', (err) => {
this.avPlayerState = VideoPlayerState.ERROR;
this.videoError = `播放异常 (code: ${err.code})`;
});
this.avPlayer.on('bufferingUpdate', (_, value) => {
this.videoIsBuffering = value < 100;
});
// 如果 XComponent 已就绪,立即绑定 surface
this.bindSurfaceAndPrepare();
} catch (err) {
this.avPlayerState = VideoPlayerState.ERROR;
this.videoError = '播放器初始化失败';
}
}
核心点解读 :入口处的
releaseAVPlayer()是幂等的------如果avPlayer为 null,什么都不做。这保证了initAVPlayer可以被多次安全调用(比如用户点击重试按钮时),不会产生多个播放器实例。
4.3 handleAVPlayerStateChange() ------ 状态映射
typescript
private handleAVPlayerStateChange(state: string): void {
switch (state) {
case 'prepared': this.avPlayerState = VideoPlayerState.READY; break;
case 'playing': this.avPlayerState = VideoPlayerState.PLAYING;
this.videoIsBuffering = false; break;
case 'paused': this.avPlayerState = VideoPlayerState.PAUSED; break;
case 'completed': this.avPlayerState = VideoPlayerState.COMPLETED; break;
case 'stopped': this.avPlayerState = VideoPlayerState.IDLE; break;
case 'error': this.avPlayerState = VideoPlayerState.ERROR;
this.videoError = '播放过程中发生错误'; break;
}
}
为什么没有 initialized 和 released?
initialized:URL 设置成功后的瞬时状态,用户不需要知道"播放器初始化了",他们只关心"能播了吗"released:player 已释放,此时avPlayer引用已置 null,UI 不需要处理
4.4 togglePlayPause() ------ 播放控制
typescript
private togglePlayPause(): void {
if (!this.avPlayer) return;
if (this.avPlayerState === VideoPlayerState.PLAYING) {
this.avPlayer.pause(); // 播放中→暂停
} else if (this.avPlayerState === VideoPlayerState.READY ||
this.avPlayerState === VideoPlayerState.PAUSED ||
this.avPlayerState === VideoPlayerState.COMPLETED) {
if (this.avPlayerState === VideoPlayerState.COMPLETED) {
this.avPlayer.seek(0, media.SeekMode.SEEK_PREV_SYNC); // 播完→回到开头
}
this.avPlayer.play(); // 开始/恢复播放
}
}
设计决策 :重播不用
release + init,而是seek(0) + play()。因为prepare是最耗时的阶段(需要解析视频头、建立解码器),而seek是毫秒级的。重播体验应该像按"重播键"一样瞬间响应,而不是像重新加载一样等待。
4.5 releaseAVPlayer() ------ 资源释放
typescript
private releaseAVPlayer(): void {
if (this.avPlayer) {
this.avPlayer.off('stateChange');
this.avPlayer.off('timeUpdate');
this.avPlayer.off('durationUpdate');
this.avPlayer.off('error');
this.avPlayer.off('bufferingUpdate');
this.avPlayer.release();
this.avPlayer = null;
}
this.avPlayerState = VideoPlayerState.IDLE;
this.videoCurrentTime = 0;
this.videoDuration = 0;
this.videoError = '';
this.videoIsBuffering = false;
this.videoSurfaceId = '';
}
关键 :必须先
off再release。如果在 release 后再 off,事件监听器可能已被系统清理,调用 off 会抛出异常。这就像退房前必须先归还钥匙------顺序错了,就会出问题。
4.6 生命周期集成点
typescript
// ── 进入智慧屏模式 ──
private async onDeviceSelected(device: FlowDevice): Promise<void> {
if (device.type === FlowDeviceType.SMART_SCREEN) {
this.initAVPlayer(); // ← 流转到智慧屏时立即初始化
}
}
// ── 离开智慧屏模式 ──
private async backToLocal(): Promise<void> {
this.releaseAVPlayer(); // ← 返回手机时释放播放器
}
// ── 页面销毁 ──
aboutToDisappear(): void {
this.releaseAVPlayer(); // ← 页面退出时释放(兜底)
}
三重释放保障:正常返回(backToLocal)、页面销毁(aboutToDisappear)、重新初始化(initAVPlayer 入口)。任意一个触发都能保证播放器被正确释放,不会内存泄漏。
五、智慧屏 UI:XComponent + 状态叠加层
5.1 整体布局结构
┌────────────────────────────────────┐
│ 📺 已流转到智慧屏 X5 [返回手机] │ ← 顶部栏
├────────────────────────────────────┤
│ ┌──────────────────────────────┐ │
│ │ XComponent (视频画面) │ │ ← 视频播放区 (45% 高度)
│ │ ┌────────────────────────┐ │ │
│ │ │ 状态叠加层 (Stack) │ │ │ LOADING: 加载动画
│ │ └────────────────────────┘ │ │ READY: 播放按钮
│ └──────────────────────────────┘ │ COMPLETED: 重播按钮
│ │ ERROR: 错误+重试
│ ▶️ 00:32 / 02:15 🍳番茄牛腩煲 │ ← 播放控制栏
├────────────────────────────────────┤
│ 第 1 步 │
│ 焯水:牛腩切块冷水入锅... │ ← 当前步骤大字展示
├────────────────────────────────────┤
│ [← 上一步] Step 1/3 [下一步 →] │ ← 底部导航
└────────────────────────────────────┘
实际的效果图:

5.2 状态与 UI 映射表
| AVPlayerState | 叠加层显示 | 点击行为 | 控制栏显示 |
|---|---|---|---|
IDLE |
暂无教学视频 / 占位提示 | 无 | ❌ |
LOADING |
LoadingProgress + "正在加载视频..." | 无 | ❌ |
READY |
▶️ 播放按钮 + 菜谱名 | 点击→播放 | ✅ |
PLAYING |
透明触摸层(点击暂停) | 点击→暂停 | ✅ |
PAUSED |
▶️ 播放按钮(已暂停) | 点击→继续 | ✅ |
COMPLETED |
✅ 播放完成 + 点击重播 | 点击→重播 | ✅ |
ERROR |
⚠️ 错误信息 + 重试按钮 | 点击→重试 | ❌ |
5.3 状态叠加层的设计考量
播放中为什么是"透明触摸层"而非"暂停按钮"?
在智慧屏场景下,用户距离屏幕较远(通常 2-3 米)。如果只放一个小暂停按钮,用户很难精准点击。整个视频区域就是一个大暂停按钮 ------点击画面任意位置即可暂停,再次点击恢复播放。这是大屏交互的黄金法则:可点击区域越大,用户体验越好。
5.4 播放控制栏
在视频区域下方显示播放进度和控制:
typescript
if (this.avPlayerState === VideoPlayerState.PLAYING ||
this.avPlayerState === VideoPlayerState.PAUSED ||
this.avPlayerState === VideoPlayerState.READY) {
Row({ space: 12 }) {
// 播放/暂停按钮
Button() {
if (this.avPlayerState === VideoPlayerState.PLAYING) {
SymbolGlyph($r('sys.symbol.pause_fill')).fontSize(20)
} else {
SymbolGlyph($r('sys.symbol.play_fill')).fontSize(20)
}
}.onClick(() => this.togglePlayPause())
// 时间显示
Text(`${this.formatTime(this.videoCurrentTime)} / ${this.formatTime(this.videoDuration)}`)
.fontSize(12).fontColor('#AAA')
Blank()
// 菜谱名称
Text(`🍳 ${this.recipe.name}`).fontSize(12).fontColor('#FF6B35')
}
}
六、代码增删改清单
| 文件 | 新增/修改 | 说明 |
|---|---|---|
entry/.../foundation/model/Recipe.ets |
修改 | 添加 videoUrl?: string 字段 |
shared/.../foundation/model/Recipe.ets |
修改 | HSP 共享模块同步添加 videoUrl |
entry/.../foundation/model/MockData.ets |
修改 | 10 道菜谱添加测试视频 URL |
shared/.../foundation/model/MockData.ets |
修改 | HSP 共享模块同步添加 |
pages/RecipeDetailPage.ets |
重大修改 | 新增 AVPlayer 集成(v4 → v5) |
RecipeDetailPage 变更详情
| 变更项 | 行数 | 说明 |
|---|---|---|
新增 import { media } from '@kit.MediaKit' |
1 | 系统多媒体 API |
新增 VideoPlayerState 枚举 + DEFAULT_VIDEO_URL |
16 | 7 态状态枚举、默认视频 URL |
| 新增 AVPlayer 状态变量 | 10 | avPlayer、xComponentController、videoSurfaceId 等 |
修改 onDeviceSelected() |
+4 | 流转到智慧屏时调用 initAVPlayer() |
修改 backToLocal() |
+2 | 返回手机时调用 releaseAVPlayer() |
修改 aboutToDisappear() |
+2 | 页面销毁时兜底释放 |
重写 buildSmartScreenLayout() |
~50 | 模拟区→XComponent + 状态叠加层 + 控制栏 |
| 新增 AVPlayer 方法集 | ~120 | initAVPlayer、releaseAVPlayer、togglePlayPause、bindSurfaceAndPrepare 等 |
七、设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| AVPlayer 放在哪一层 | UI 层(RecipeDetailPage 内部) | AVPlayer 与 XComponent 强绑定,生命周期与页面一致;无复用场景,不需要抽 Business 层 |
| 视频 source | url 属性(HTTP/HTTPS) |
支持网络视频,mock 使用 W3C 公开测试视频;生产环境由后端返回 CDN 地址 |
| surface 绑定时机 | 双路径互补 | 解决 AVPlayer 与 XComponent 异步就绪的时序问题 |
| 播放控制方式 | 全屏点击叠加层 | 智慧屏远距离交互,大面积点击区域比小按钮更友好 |
| 重播实现 | seek(0) + play() |
比重新 prepare 快得多,用户体验更好 |
| 错误恢复 | 手动重试按钮 | 网络不稳定时自动重试可能陷入死循环 |
| 默认视频 | DEFAULT_VIDEO_URL 常量 |
兜底策略,生产环境可改为空,显示「暂无视频」 |
| 时间格式化 | 自实现 formatTime(ms) |
将毫秒转为 mm:ss 显示,简洁高效 |
八、运行验证与效果
- 将程序部署到真机或者手机模拟器。
- 点击首页中任意菜谱,进入详情页面。

- 在详情页中点击左上角的图标,在弹窗中选择智慧屏。

- 进入智慧屏点击播放,查看效果(暂未是真实做菜视频,先体验效果)。

- 在播放过程中,看到视频进度和底部的步骤是协同的 (
模拟人在执行控制操作)。



九、本阶段总结与下篇预告
本篇我们将《灵犀厨房》智慧屏上的「模拟视频区」升级为了真正的 AVPlayer 视频播放器:
- 数据层 :为
Recipe接口添加videoUrl可选字段,10 道菜谱均配置测试视频 URL - 播放器层:完整实现 AVPlayer 生命周期------创建→URL→surface绑定→prepare→播放控制→释放
- UI 层:XComponent 渲染 + 7 态叠加层 + 播放控制栏,智慧屏交互体验完整
- 异步时序 :双路径互补的
bindSurfaceAndPrepare()设计,优雅解决异步就绪问题 - 资源管理:三重释放保障,杜绝内存泄漏
现在的全场景体验:
📱 手机选菜谱 → 📋 流转到平板看步骤 → 📺 流转到智慧屏,真·视频教学开始播放!
下篇预告:《交互动效:转场、列表动画与趣味反馈》。我们将为《灵犀厨房》的页面转场、列表滚动和按钮点击加入流畅的动画与趣味反馈,让 App 从"能用"进化为"好用"。
📚 本系列持续更新中:下一篇将为 App 注入动效灵魂,让交互如丝般顺滑。
🔗 专栏入口:《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包 :包括第1-15篇所有代码 + 架构文档 + Flask 后端
如果你觉得这篇文章对您有所帮助,麻烦您动动发财之手点赞 👍、收藏 ⭐ 和评论 💬。谢谢大家!!纯血鸿蒙,用心造厨。我们下一篇见!