1. 从零拆解一个视频播放器:为什么我选择 AVPlayer 而不是 Video 组件
做过移动端应用的人都有一个共识:视频播放这块,看起来简单,实际上坑特别多。系统自带的播放组件用起来确实省事,但一旦产品经理提出"自定义控制栏""倍速播放""精准 seek""后台播放"这类需求,原生封装组件就开始捉襟见肘了。我最近在一个内容类应用里负责视频模块,踩了不少坑之后,最终选定了基于 AVPlayer 自建播放器的方案,这里把整个思路和实操过程完整记录下来。
先说清楚这个项目到底在做什么。核心目标是在一个内容信息流应用里实现视频播放能力,要求支持:列表内自动播放、点击进入全屏播放、自定义播放控制栏、播放进度实时回调、暂停/恢复状态管理、以及和页面滑动容器的联动(滑走暂停、滑回恢复)。技术栈是 ArkTS + AVPlayer + XComponent,运行环境是 HarmonyOS NEXT。
为什么不用系统封装的 Video 组件?这是很多人第一个会问的问题。Video 组件确实开箱即用,几行代码就能跑起来,但它的可定制性非常有限。控制栏样式改不了、播放器内核行为改不了、和外部状态联动的粒度也很粗。而 AVPlayer 是更底层的媒体播放接口,它把播放状态、进度、缓冲、错误全部通过事件回调暴露出来,你可以完全掌控播放器的生命周期。代价就是你需要自己处理渲染 surface、自己管理状态机、自己写控制逻辑。
这里有一个关键概念需要先理清楚:AVPlayer 负责的是"解码和播放"这件事本身,它不负责"把画面画到哪里"。画面渲染需要依赖 XComponent 提供的 Surface。你可以把 AVPlayer 理解成一个发动机,XComponent 理解成车轮,发动机提供动力,但动力要传到车轮上才能跑起来。两者通过 surfaceId 进行绑定。
这个架构选择带来的最大好处是解耦。播放逻辑和渲染逻辑分离之后,你可以在不同的页面复用同一个播放器实例,也可以在一个页面里管理多个播放器实例(比如列表内多个视频)。这在信息流场景里非常重要,因为用户滑动列表时,可能同时存在好几个视频组件,你需要精确控制哪个在播、哪个该停。
2. 核心架构设计:状态机、渲染层与控制层的三层分离
2.1 播放器状态机的设计思路
AVPlayer 本身有一套状态定义,包括 idle、initialized、prepared、playing、paused、completed、stopped、released 等。如果你直接把这些状态透传给 UI 层,代码会变得非常混乱,因为 UI 关心的状态和播放器内部状态并不是一一对应的。
我的做法是在 AVPlayer 状态之上再抽象一层"业务状态",只暴露四个状态给 UI:加载中、播放中、已暂停、播放失败。这四个状态覆盖了用户能感知到的所有情况。AVPlayer 的 prepared 和 playing 都映射为"播放中",completed 映射为"已暂停"(因为播完了停在最后一帧),error 映射为"播放失败"。
为什么要做这层映射?因为 UI 层不需要知道"播放器正在缓冲"和"播放器已经准备好但还没开始播"的区别,它只需要知道"现在该显示 loading 还是显示播放按钮"。状态越少,UI 逻辑越简单,出 bug 的概率越低。
2.2 XComponent 的渲染绑定机制
XComponent 是 ArkTS 提供的原生渲染组件,它对外暴露一个 surfaceId。AVPlayer 通过surfaceId属性把视频帧渲染到这块 surface 上。这里有一个时序问题非常关键:必须等 XComponent 的 onLoad 回调触发之后,才能拿到有效的 surfaceId,才能去初始化 AVPlayer 并绑定 surface。
我见过很多人在这里踩坑,他们在 aboutToAppear 里就去创建 AVPlayer,结果 surfaceId 还是空的,视频黑屏。正确的顺序是:
- 页面加载,XComponent 开始创建
- XComponent 的 onLoad 回调触发,拿到 surfaceId
- 用这个 surfaceId 去配置 AVPlayer 的 surfaceId 属性
- 调用 prepare() 开始准备播放
- 监听 prepared 事件,调用 play()
这个顺序不能乱,乱了就是黑屏或者报错。
2.3 控制层与播放层的通信方式
控制层(播放/暂停按钮、进度条、时间显示)和播放层(AVPlayer 实例)之间通过事件回调和状态变量通信。AVPlayer 的on('stateChange')回调负责把播放器状态变化通知给控制层,on('timeUpdate')回调负责把当前播放进度通知给进度条。
这里有个性能细节:timeUpdate的触发频率大约是每秒 1 次,如果你需要更平滑的进度条动画,不能只依赖这个回调,需要自己在控制层用定时器做插值。但定时器频率也不要太高,100ms 一次足够了,太高会掉帧。
3. AVPlayer 初始化与 XComponent 绑定的完整实操
3.1 创建 AVPlayer 实例的正确姿势
创建 AVPlayer 实例本身很简单,一行代码:
import media from '@ohos.multimedia.media'; let avPlayer: media.AVPlayer = await media.createAVPlayer();但创建之后立刻就要注册各种事件监听,否则你会错过早期状态变化。我建议把事件注册封装成一个独立方法,在 createAVPlayer 之后立即调用:
private registerPlayerEvents() { this.avPlayer.on('stateChange', (state: string) => { switch (state) { case 'initialized': // 此时可以设置 surfaceId this.avPlayer.surfaceId = this.surfaceId; this.avPlayer.prepare(); break; case 'prepared': // 准备完成,可以播放 this.avPlayer.play(); break; case 'playing': this.updateBusinessState('playing'); break; case 'paused': this.updateBusinessState('paused'); break; case 'completed': this.updateBusinessState('paused'); break; case 'error': this.updateBusinessState('error'); break; } }); this.avPlayer.on('timeUpdate', (time: number) => { this.currentTime = time; }); this.avPlayer.on('error', (err: BusinessError) => { console.error(`AVPlayer error: ${err.code}, ${err.message}`); this.updateBusinessState('error'); }); }注意initialized状态这个节点。当你调用avPlayer.url = xxx或者avPlayer.fdSrc = xxx之后,播放器会进入 initialized 状态。这个时候才能设置 surfaceId,然后调用 prepare()。如果你在 initialized 之前设置 surfaceId,是无效的。
3.2 XComponent 的配置与 surfaceId 获取
XComponent 在 ArkTS 里的用法如下:
XComponent({ id: 'videoSurface', type: XComponentType.SURFACE, controller: this.xComponentController }) .onLoad(() => { this.surfaceId = this.xComponentController.getXComponentSurfaceId(); this.initPlayer(); }) .onDestroy(() => { this.releasePlayer(); }) .width('100%') .height('100%')type必须指定为XComponentType.SURFACE,这是视频渲染必须的模式。onLoad回调里拿到 surfaceId 之后再去初始化播放器,这个顺序前面强调过了。
onDestroy里一定要释放播放器资源。我遇到过因为忘记释放导致内存泄漏、播放多个视频后应用卡死的情况。释放的逻辑是:先调用avPlayer.release(),然后把引用置空。
3.3 播放源设置与 prepare 流程
设置播放源有两种方式:网络 URL 和本地文件。网络 URL 直接赋值给avPlayer.url,本地文件通过avPlayer.fdSrc设置文件描述符。
// 网络视频 this.avPlayer.url = 'https://example.com/video.mp4'; // 本地视频 let file = fs.openSync(localPath, fs.OpenMode.READ_ONLY); this.avPlayer.fdSrc = { fd: file.fd };设置完播放源之后,播放器会自动进入 initialized 状态,触发 stateChange 回调。在回调里设置 surfaceId 并调用 prepare()。prepare() 是异步的,准备完成后会触发 prepared 状态。
这里有个经验:prepare() 可能会失败,比如网络不通、视频格式不支持。所以 error 事件的监听一定要在 prepare 之前就注册好,否则错误会被吞掉。
4. 播放控制与状态同步的细节处理
4.1 播放、暂停、Seek 的实现与边界情况
播放和暂停直接调用avPlayer.play()和avPlayer.pause()。但要注意,这两个操作都是异步的,调用之后状态不会立刻变化,要等 stateChange 回调。所以 UI 上的按钮状态不能直接跟着点击事件变,要跟着 stateChange 变。
Seek 操作是坑最多的。avPlayer.seek(timeMs, media.SeekMode.SEEK_CLOSEST)有两个模式:SEEK_CLOSEST 和 SEEK_PREVIOUS_SYNC。前者会 seek 到最接近的时间点,后者会 seek 到前一个关键帧。对于精确 seek(比如用户拖动进度条),用 SEEK_CLOSEST;对于快速 seek(比如快进几秒),用 SEEK_PREVIOUS_SYNC 性能更好。
Seek 完成会触发seekDone事件。如果你在 seek 过程中连续调用多次 seek,可能会出现状态错乱。我的做法是加一个 seek 锁,seek 进行中不接受新的 seek 请求。
4.2 进度回调与进度条平滑更新
timeUpdate回调每秒触发一次,直接用它更新进度条会一顿一顿的。我的方案是在控制层维护一个定时器,每 100ms 更新一次进度条位置,进度值基于最后一次 timeUpdate 的值加上时间差估算。
private startProgressTimer() { this.progressTimer = setInterval(() => { if (this.businessState === 'playing') { this.displayTime = this.currentTime + (Date.now() - this.lastUpdateTime); this.progress = this.displayTime / this.duration; } }, 100); }这个估算不需要特别精确,用户感知不到几十毫秒的误差。关键是视觉上要平滑。
4.3 与 Swiper 容器的联动:滑走暂停、滑回恢复
这是信息流场景的核心需求。Swiper 的onChange回调会告诉你当前滑到了第几页。你需要在回调里做两件事:暂停上一个视频,播放当前视频。
Swiper() { // ... } .onChange((index: number) => { if (this.currentIndex !== index) { this.pauseVideo(this.currentIndex); this.currentIndex = index; this.playVideo(index); } })但这里有个陷阱:Swiper 的 onChange 在快速滑动时可能触发多次,导致播放/暂停状态错乱。我的做法是加一个防抖,300ms 内的连续 onChange 只处理最后一次。
还有一个更隐蔽的问题:当视频暂停时,Swiper 应该可以正常滑动;当视频播放时,如果视频区域有手势冲突,可能需要禁用 Swiper 的滑动。这个要根据具体交互设计来定。
5. 常见问题排查与性能优化实录
5.1 黑屏、无声、播放失败的排查路径
视频播放出问题,排查顺序应该是:surfaceId 是否有效 → 播放源是否可访问 → 解码器是否支持 → 状态机是否卡住。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 黑屏但有声音 | surfaceId 未设置或设置时机不对 | 检查是否在 initialized 状态后才设置 surfaceId |
| 有画面但无声音 | 音频流未解码或静音 | 检查视频文件音频编码格式 |
| 完全黑屏无声音 | 播放源不可访问 | 用浏览器或播放器验证 URL 是否可访问 |
| 播放几秒后卡住 | 缓冲不足或网络抖动 | 监听 bufferingUpdate 事件 |
| 报错 code 5400102 | 操作不被允许 | 检查当前状态是否支持该操作 |
我最常遇到的是 code 5400102,这个错误通常是状态机操作顺序不对导致的。比如在 prepared 之前调用 play(),或者在 released 之后调用 pause()。解决办法就是严格按状态机顺序操作,每个操作前先检查当前状态。
5.2 内存泄漏与多实例管理
列表里如果有多个视频,每个视频都创建一个 AVPlayer 实例,内存会爆炸。我的策略是:只保留当前可见视频的播放器实例,滑走的视频释放播放器,只保留封面图。
释放播放器的正确流程:
async releasePlayer() { if (this.avPlayer) { this.avPlayer.off('stateChange'); this.avPlayer.off('timeUpdate'); this.avPlayer.off('error'); await this.avPlayer.release(); this.avPlayer = undefined; } }注意要先 off 掉所有事件监听,再 release。否则 release 过程中触发的事件回调可能会访问已经释放的对象,导致崩溃。
5.3 首帧显示速度优化
用户点击视频后,从黑屏到出现第一帧的时间越短越好。优化手段有几个:预加载(在视频进入可视区域前就创建播放器并 prepare)、使用封面图占位(prepare 完成前显示封面)、以及设置合适的缓冲策略。
预加载要控制好度,不能所有视频都预加载,否则内存扛不住。我的做法是只预加载当前页和下一页的视频。
6. 我踩过的坑与实战心得
第一个坑是XComponent 的 onLoad 时机。我一开始在 aboutToAppear 里初始化播放器,结果 surfaceId 拿不到,视频一直黑屏。后来改成在 onLoad 里初始化才解决。这个坑的本质是没理解 XComponent 的创建是异步的。
第二个坑是状态回调的时序。AVPlayer 的 stateChange 回调是异步的,而且可能连续触发多次。我一开始在回调里直接改 UI 状态,结果出现了"播放中"和"已暂停"来回跳的情况。后来改成用一个状态变量记录最新状态,UI 只读这个变量,才稳定下来。
第三个坑是Swiper 联动时的竞态。快速滑动时,onChange 触发多次,pause 和 play 交叉执行,导致播放器状态错乱。加了防抖之后解决。
第四个坑是释放播放器时的崩溃。忘记 off 事件监听就 release,导致回调访问空对象。这个坑排查了很久,因为崩溃日志指向的是回调函数内部,而不是 release 调用处。
最后一个心得:视频播放这块,状态管理比功能实现更重要。功能就那么几个,但状态组合非常多。建议在动手写代码之前,先把状态机画清楚,把每个状态之间的转换条件列出来,能省掉后面大量的调试时间。