1. 从静态菜谱到动态教学:智慧屏缺的正好是“播放能力”
厨房里最怕的不是锅没热,而是屏幕上写着“切丝、焯水、爆香”,手却不知道从哪里开始。做“灵犀厨房”这个智慧屏项目的时候,这个问题一直卡着我。直到我在HarmonyOS 6.1里用AVPlayer把教学视频嵌进菜谱页,整个产品才算真正“活”过来。
1.1 厨房大屏的尴尬:图片轮播讲不清“怎么做”
“灵犀厨房”是跑在智慧屏上的全场景应用,核心场景是做饭时看着屏幕学做菜。早期版本用的是图片轮播:一张切好的土豆丝图,配上一句“切成均匀细丝”。听起来没毛病,但真正进厨房测试时,用户反复问的是同一句话——“这一步到底是怎么切的?切多细?”
静态图片能展示结果,但展示不了过程。尤其像颠勺、揉面、给鱼改刀、观察油温这类动作,文字和图片的还原度非常有限。我当时认识到,菜谱页必须有一个能播视频的模块,而且这个模块不能是打开系统播放器那种割裂体验,必须直接嵌在菜谱页里,跟步骤列表、食材清单、计时器融为一体。
HarmonyOS 6.1的AVPlayer,正好承担这个角色。它是系统媒体框架里负责音视频播放的核心组件,支持本地资源、网络流、HLS等常见直播点播场景,而且能通过surface直接渲染到ArkUI的XComponent上。也就是说,视频不是“弹窗出去播”,而是长在页面里,这才能做后续的多媒体交互。
1.2 AVPlayer在灵犀厨房里的定位与技术选型
在动手写代码之前,我先理清了一个问题:智慧屏上的教学视频,到底需要播放器具备哪些能力?
第一是“嵌入页面”,不是“跳转播放”。视频画面必须作为页面的一部分,下方还能保留步骤文字、调料用量。第二是“可控”,用户可能要暂停下来看手部动作,要拖动进度条回看某一步,甚至要0.5倍速慢放。第三是“生命周期安全”,离开菜谱页、切换菜品、熄屏待机时,播放器不能残留、不能继续出声。
基于这三点,AVPlayer是比Video组件更偏底层的选择。Video组件用起来简单,但定制能力、状态控制粒度和生命周期管理都不够细。AVPlayer则把播放器状态完全暴露给我们,相当于把“做完一道菜”的流程拆成了备菜、下锅、出锅、洗锅几个明确阶段,我们可以在每个阶段插入自己的逻辑。
如果你只想快速验证“大屏能不能播视频”,直接堆一个Video组件确实更快。但如果你做的是正式项目,后面还要接推荐位自动播放、商品讲解、多设备续播,那直接上AVPlayer反而省事。
2. AVPlayer播放链路:从视频源到屏幕的一整套状态机
AVPlayer最需要理解的不是play、pause这两个方法,而是它的状态机。任何一个播放器中间环节出错,都会卡在某个状态,UI却没反应,这是新手最容易踩的坑。
2.1 播放器不是“一放了之”:AVPlayer的状态迁移
AVPlayer有八种常用状态:idle、initialized、prepared、playing、paused、completed、stopped、released,再加上一个出错时的error。可以这么记:idle是播放器刚创建,还没分配资源;initialized是已经设置了播放源,但还没准备好;prepared是加载好元数据,随时能播;playing是真正开始输出画面;paused是暂停在某个时间点;completed是播完了;stopped是主动停止,但还能重置;released是彻底释放资源。
我见过不少人直接调play(),结果没声音没画面,就是因为跳过了准备流程。播放器的正常路径必须是:
创建播放器 -> 设置视频源 -> 绑定surfaceId ->prepare()-> 进入prepared后再play()。
StateChange事件就是这张状态机的实时上报。我在项目里一定会先挂监听,再走后续流程,避免错过状态变化:
import media from '@ohos.multimedia.media'; this.avPlayer.on('stateChange', (state: media.AVPlayerState) => { console.info(`灵犀厨房 AVPlayer state: ${state}`); if (state === 'prepared') { // 进入准备完成状态,此时可以获取时长,也可以发起播放 this.isPrepared = true; } });不同状态之间的流转是有条件的,不能胡思乱想地乱跳。比如在playing状态直接release()会报错,必须先把播放器停到安全状态。这跟炒菜一个道理,锅还烧着就想直接关煤气端走,容易出事。
2.2 视频画面的承载:XComponent和surface的关系
AVPlayer本身不负责画界面,它只负责把解码后的画面输出到一个surfaceId上。在HarmonyOS 6.1里,ArkUI侧提供了一个XComponent,类型设为surface时,它会生成一个原生surface,我们把它的ID交给AVPlayer,视频画面就渲染上去了。
XComponent的写法很固定:
@State surfaceId: string = ''; private xComponentController: XComponentController = new XComponentController(); build() { XComponent({ id: 'recipeVideoSurface', type: 'surface', controller: this.xComponentController }) .width('100%') .height(320) .onLoad(() => { this.surfaceId = this.xComponentController.getXComponentSurfaceId(); this.initPlayer(); }) }有一个细节容易被忽略:surface的创建是异步的,onLoad回调才是安全获取surfaceId的时机。如果你在aboutToAppear里急着初始化播放器,大概率拿到的是空字符串,视频就一直黑屏。
surface和播放器是一对一关系。切换视频时,如果旧播放器还占着surface,新播放器要么渲染不出来,要么画面花屏。正确做法是先把旧播放器释放干净,再把新的surfaceId交给新播放器。这个顺序问题我在后面“实测中遇到的坑”里会展开讲。
3. 把教学视频嵌进智慧屏:ArkTS代码级实现
光说不练没意思。这一节给出“灵犀厨房”里实际可用的代码骨架,从创建播放器到释放,完整串一遍。
3.1 初始化播放器与绑定画面
初始化这一步要做的事很多:创建AVPlayer、绑定错误监听、绑定状态监听、设置surfaceId。我会把它包成一个独立的initPlayer方法,方便页面加载时调用。
import media from '@ohos.multimedia.media'; const TAG = 'LingxiKitchen'; export class RecipeVideoPlayer { private avPlayer: media.AVPlayer | null = null; private surfaceId: string = ''; private isPrepared: boolean = false; private loadToken: number = 0; async initPlayer(surfaceId: string) { this.surfaceId = surfaceId; this.loadToken++; const currentToken = this.loadToken; // 每次都先清掉旧播放器,避免状态混乱 await this.releasePlayer(); try { this.avPlayer = await media.createAVPlayer(); this.avPlayer.on('stateChange', (state: media.AVPlayerState) => { if (currentToken !== this.loadToken) { return; // 收到的是旧播放器的状态,直接忽略 } console.info(`${TAG} state -> ${state}`); if (state === 'prepared') { this.isPrepared = true; } }); this.avPlayer.on('error', (err) => { console.error(`${TAG} error -> ${JSON.stringify(err)}`); }); this.avPlayer.surfaceId = this.surfaceId; } catch (e) { console.error(`${TAG} initPlayer failed: ${JSON.stringify(e)}`); } } }看到我用了loadToken,这个不是多余的。真实场景里用户可能快速切换菜谱,第一次加载还没完成,第二次加载又开始了,如果不对播放器实例做区分,旧播放器的回调会跑到新逻辑里,出现“视频刚准备完就被释放”的怪现象。
3.2 设置播放源、准备与自动播放
播放源可以来自本地resource,也可以来自网络地址。在智慧屏场景中,我建议教学视频优先走本地打包,或者放在自己的内容服务器上,用HLS切片,这样拖动进度条体验更好。
async loadVideo(videoUrl: string) { if (!this.avPlayer) { console.error(`${TAG} player not initialized`); return; } try { this.isPrepared = false; this.avPlayer.url = videoUrl; await this.avPlayer.prepare(); // 等待 stateChange 进入 prepared 之后,再执行播放 if (this.isPrepared) { this.avPlayer.play(); } } catch (e) { console.error(`${TAG} loadVideo error: ${JSON.stringify(e)}`); } }这里有一个时序点:prepare()返回之后,播放器不一定已经进入prepared状态,因为状态切换是异步上报的。所以我更推荐在stateChange里收到prepared再调play(),而不是在await prepare()之后直接调。如果视频源本身有问题,直接调play()会得到一个无处安放的错误回调,排查起来很麻烦。
3.3 播放控制与状态监听
播放、暂停、跳转这三个能力是多媒体交互的地基。在“灵犀厨房”里,视频下面是步骤区,用户点击步骤会触发跳转,点击画面会暂停,再次点击继续。
play() { if (this.avPlayer && this.isPrepared) { this.avPlayer.play(); } } pause() { if (this.avPlayer && this.isPrepared) { this.avPlayer.pause(); } } async seekTo(timeMs: number) { if (this.avPlayer && this.isPrepared) { await this.avPlayer.seek(timeMs); } } release() { this.loadToken++; this.releasePlayer(); }播放进度这块,我会在prepared状态时读取duration得到总时长,然后监听timeUpdate事件更新当前进度。UI层只需要维护两个State变量,就能驱动进度条和剩余时间:
this.avPlayer.on('timeUpdate', (time: number) => { this.currentTime = time; });3.4 离开页面时的释放逻辑
很多播放器问题都是“只记得播,不记得关”引起的。智慧屏常驻场景下,如果用户离开菜谱页后播放器还在后台跑,轻则白耗内存,重则跟下一个页面的播放器抢音频焦点。
我在页面的aboutToDisappear里做了统一释放:
aboutToDisappear() { this.videoPlayer.release(); }release()不是stop()。stop()还能让播放器回到stopped状态,之后可以重新prepare;release()是彻底销毁播放器,释放底层解码器和surface资源。离开页面退役的播放器,就应该直接release(),而不是留着“方便下次复用”。
4. 多媒体交互体验:播放器与UI联动的几个关键点
做智慧屏和做手机App最大的不同是:用户距离远、操作方式少、观看时间长。视频模块不能只是“能播”,还得跟整个页面有交互感。
4.1 进度条、时间文案与手势控制
智慧屏通常用遥控器操作,实体按键只有上下左右和确认,所以交互要尽量简单。我把进度条设计成一种“轻量滑块”:默认只显示一条细线和时间文案,用户按确认键后进入“拖动调节”模式,再用方向键微调。
控件层用ArkUI的Slider就能实现:
Slider({ value: this.currentTime, min: 0, max: this.duration, style: SliderStyle.OutSet }) .onChange((value: number, mode: SliderChangeMode) => { if (mode === SliderChangeMode.Moving) { this.videoPlayer.pause(); } else if (mode === SliderChangeMode.End) { this.videoPlayer.seekTo(Math.floor(value)); this.videoPlayer.play(); } })拖动过程中先暂停,松手后再定位播放,这个交互细节很重要。如果不暂停,画面和进度条会互相打架,用户根本看不清拖到了哪一秒。
时间文案我习惯用两个文本拼在进度条两端:左边是当前时间,右边是总时长。格式统一为“分:秒”,超过一小时再显示小时,别让“5:67”这种数据露出来。
4.2 音频焦点与智慧屏场景下的声音处理
智慧屏往往摆在一室一厅的核心位置,声音一响会影响全家。所以播放教学视频时,我不建议默认满音量。
AVPlayer可以通过setVolume控制播放音量:
// 初始音量给到 0.7,留出余量 this.avPlayer.setVolume(0.7);如果页面里同时有其他声音模块,比如语音助手、计时器提醒,一定要做好音频焦点协商。我的经验是:视频播放时,其他提示音要降低音量或者抢到焦点时自动暂停视频;视频被系统打断后,要能恢复播放而不是一直僵在那里。这里不需要复杂逻辑,只需要在on('error')和音频打断回调里把UI状态复位。
4.3 封面、加载蒙层与错误重试的交互设计
视频加载是需要时间的,尤其第一次拉流。如果用户看到一片黑,会觉得设备卡死了。我在XComponent上面套了一层Stack,先显示菜谱封面图,等播放器进入prepared或者playing后再淡出封面。
错误处理也必须做。智慧屏的网络环境有时候不稳定,error回调触发后,不能只打日志,得给用户一个明确的“加载失败,重试”按钮,同时把底层播放器reset回可复用状态:
async handleError() { if (this.avPlayer) { await this.avPlayer.reset(); this.isPrepared = false; } // 通知UI显示错误重试区域 this.videoState = VideoState.ERROR; }这样处理之后,用户按一下重试,就可以重新走loadVideo流程,而不是被迫退出页面重新进。
5. 实测中遇到的坑:格式、网络缓冲、生命周期
以下这些问题,都是我在这套智慧屏项目里真正踩过的,每一次排查都花了不少时间。既然做“实战”记录,这些坑得原原本本写出来。
5.1 视频源格式与封装:别让播放器卡在“黑屏”
我在早期测试时拿了一个MKV格式的视频文件,在电脑上播放毫无问题,塞到智慧屏应用里直接黑屏。查了日志才知道,当前AVPlayer的解码支持是有范围的,不能拿“电脑能播”当作“播放器能播”。
我的经验是优先使用下面这个组合:
| 场景 | 推荐封装 | 推荐编码 | 音频编码 |
|---|---|---|---|
| 本地教学视频 | MP4 | H.264 | AAC |
| 网络点播长视频 | HLS (m3u8) | H.264 | AAC |
| 临时短视频 | MP4 / fast start | H.264 | AAC |
尽量不要碰MKV、FLV、WMV这类封装,也不要直接用高码率HEVC,除非你明确知道目标设备的硬解能力。MP4里的moov原子最好前置,否则播放器需要先下载一大段索引才能开始播放,拖进度条时还会卡顿。
这个问题本质上不是AVPlayer的bug,而是多媒体处理里的“源格式”问题。做内容管理后台时,我会把上传视频统一转码成H.264+AAC的MP4,再根据网络情况生成720P和1080P两档清晰度。
5.2 切换菜谱时的播放器状态冲突
“灵犀厨房”的菜谱列表和详情页是联动的,用户看完红烧肉,返回列表又点开糖醋排骨,这时详情页可能重新创建,也可能被系统复用。如果播放器没有跟页面生命周期同步,就会出现两个播放器实例同时存在的情况。
我更推荐把播放器实例放在“详情页控制器”里,而不是系统单例里。每个详情页持有一个RecipeVideoPlayer,页面销毁时统一释放。页面还没销毁但视频源变化时,也要走完整的“释放旧播放器 -> 创建新播放器”流程,不能只改URL。
这个坑的症状很隐蔽:第二次进入菜谱页时,视频偶尔有画面没声音,偶尔有声音没画面。原因就是surface被多个播放器轮番绑定,状态串了。用了loadToken加上严格释放顺序后,这个问题没再出现过。
5.3 低端智慧屏的surface释放时序
低端设备上,surface的创建和销毁速度比高端设备慢不少。如果页面退出动画还没结束,我就把XComponent销毁了,同时调用播放器release(),有概率导致底层surface指针失效,应用闪退。
解决办法是把播放器释放时机放到onPageHide里,并且不要立刻销毁XComponent;如果必须销毁,就等一帧再释放播放器。代码上可以用setTimeout缓一下,虽然听起来不太“优雅”,但在某些硬件上非常管用:
onPageHide() { // 先停止播放,等页面动画结束后再彻底释放 this.videoPlayer.pause(); setTimeout(() => { this.videoPlayer.release(); }, 500); }另外,如果XComponent被用在if/else分支里,条件切换时务必要在分支离开前释放播放器。ArkUI的组件销毁是异步的,不能假设XComponent还在页面上。
6. 从厨房到全场景:一种可复用的多媒体嵌入打法
视频嵌入这件事做完之后,我突然发现,这套东西的可复制性极强。本质上,它就是一个“页面内嵌视频播放器”的标准方案,适用于各种大屏场景。
6.1 同样的模板用在智慧销售屏与导览屏
只要把视频地址换成商品讲解视频,把步骤列表换成商品参数,把封面换成商品主图,这套AVPlayer方案就变成了一个智慧销售大屏模板。线下门店的导购屏、电梯广告屏、展会互动屏,本质上都是同一套逻辑:一个页面、一张封面、一个播放器、一组控制按钮、一套错误重试机制。
我在项目文档里把“灵犀厨房”的视频模块做成了独立组件,UI通过参数注入,视频源通过数据驱动。换成其他项目时,只需要改数据协议和视觉样式,播放链路完全不用动。这就是多媒体交互设计的价值:方案不是为某一个菜品定制的,而是为一类屏幕定制的。
6.2 后续扩展方向:断点续播、多端协同与内容推荐
HarmonyOS 6.1的“全场景”不只是说说。我在“灵犀厨房”里已经留了几个扩展点:第一个是断点续播,把播放时间和视频URL写入本地数据库,用户下次打开同一个菜谱,直接接着上次进度播。第二个是多端协同,手机端浏览菜谱时,一键把视频地址和播放位置无缝流转到大屏上,大屏自动续播。第三个是内容推荐,视频播完后,根据当前菜品关联推荐下一个教学视频,让用户连续学下去。
这些方向都不需要推翻现有代码,播放器层依然是AVPlayer,变化的是上层的数据编排和状态同步。
最后再分享一个我实际做完的体会:智慧屏上的视频功能,难点从来不在“怎么调起一个播放器”,而在于能不能把播放器的生命周期、页面生命周期和用户操作节奏对齐。AVPlayer给你的不是一串API,而是一整套关于“什么时候该播、什么时候该停、什么时候该释放”的思路。想明白这一点,你手里的智慧屏,才算真正“活”起来了。