21、视频播放器架构:播放器分层设计、解码层/渲染层/控制层、状态机设计、事件总线
做视频播放器,最怕什么?
我最怕的是——代码写到最后成了一团乱麻。播放、暂停、快进、切换清晰度、网络断了重连……各种状态交织在一起,逻辑散落在Activity、Fragment、甚至Adapter里。改一个bug,冒出三个新bug。
为什么会这样?
说白了,就是架构没想清楚。今天我们就聊聊播放器的分层设计,这也是我这些年踩坑踩出来的经验。
播放器分层设计:三层架构
我个人习惯把播放器拆成三层:解码层、渲染层、控制层。每一层各司其职,层与层之间通过接口通信,不直接依赖。
核心原则:上层依赖下层,下层不感知上层。控制层可以调用解码层和渲染层,但解码层不知道控制层的存在。
解码层:最底层的苦力活
解码层负责把压缩的视频数据(H.264、H.265等)解码成原始帧。Android上主要用MediaCodec做硬解,FFmpeg做软解兜底。
我在项目中遇到过一个问题:某些低端机型硬解H.265会黑屏,但软解没问题。所以解码层必须支持动态切换——硬解不行就降级到软解,用户甚至感知不到。
经验之谈:解码层不要直接操作UI。它只负责输出原始帧,至于帧怎么显示、什么时候显示,那是渲染层的事。我曾经见过有人把SurfaceView引用直接传到解码线程里……嗯,后来那个项目崩得很惨。
解码层的核心接口大概长这样:
interface IDecoder {
fun init(config: DecoderConfig): Boolean
fun start()
fun pause()
fun stop()
fun release()
fun seekTo(positionMs: Long)
fun setOutputSurface(surface: Surface)
fun getCurrentPosition(): Long
fun getDuration(): Long
}
注意看setOutputSurface这个方法。解码层不关心Surface是谁给的,它只管往上面填数据。这就是分层的好处——解耦。
渲染层:画面和声音的调度中心
渲染层拿到解码后的帧,要做三件事:
- 视频渲染:把帧送到SurfaceView或TextureView上显示
- 音频播放:通过AudioTrack输出声音
- 音视频同步:这是最头疼的部分,后面细说
渲染层通常跑在单独的线程里,用一个循环不断从解码层取帧,然后根据时间戳决定是立即显示还是等待。
注意:渲染层不要直接持有Activity或Fragment的引用。用回调或者接口的方式把事件抛出去。否则旋转屏幕时,你可能会遇到一个经典的IllegalStateException——"Surface已被销毁"。
控制层:播放器的"大脑"
控制层是用户直接交互的那一层。播放、暂停、拖动进度条、切换清晰度……所有业务逻辑都在这里编排。
但控制层最核心的,其实是状态机。
状态机设计:让播放器不乱来
播放器有很多状态:空闲、初始化中、准备就绪、播放中、暂停中、缓冲中、播放结束、出错……
如果没有状态机,你可能会写出这样的代码:
// 糟糕的写法
if (isPlaying && !isPaused && !isBuffering && !isError) {
// 执行暂停
}
这种布尔变量满天飞的代码,改着改着就疯了。我建议用枚举+状态转换表来管理。
| 当前状态 | 触发事件 | 下一状态 | 动作 |
|---|---|---|---|
| IDLE | PLAY | INITIALIZING | 开始初始化解码器 |
| INITIALIZING | PREPARED | READY | 通知UI可播放 |
| READY | PLAY | PLAYING | 开始渲染 |
| PLAYING | PAUSE | PAUSED | 暂停渲染线程 |
| PLAYING | BUFFERING_START | BUFFERING | 显示缓冲动画 |
| BUFFERING | BUFFERING_END | PLAYING | 继续播放 |
| 任何状态 | ERROR | ERROR | 显示错误信息 |
| 任何状态 | STOP | IDLE | 释放资源 |
状态机的实现,我推荐用有限状态机(FSM)模式。每个状态是一个独立的对象,负责处理该状态下允许的事件。
class PlayingState : PlayerState {
override fun onEvent(event: PlayerEvent): PlayerState? {
return when (event) {
PlayerEvent.PAUSE -> PausedState()
PlayerEvent.BUFFERING_START -> BufferingState()
PlayerEvent.STOP -> IdleState()
PlayerEvent.ERROR -> ErrorState()
else -> null // 忽略非法事件
}
}
}
这样做的好处是:非法操作根本不会被执行。比如在IDLE状态下调用暂停,状态机会直接忽略,不会崩溃也不会产生奇怪的行为。
事件总线:让各层"悄悄"通信
三层架构有个问题:控制层怎么知道解码层解码失败了?渲染层怎么通知控制层画面渲染好了?
直接回调当然可以,但回调多了,接口就变得臃肿。我习惯引入一个轻量级的事件总线。
事件总线说白了就是一个全局的消息通道。任何一层都可以往里面发事件,任何一层也可以订阅自己关心的事件。
事件总线的核心价值:让层与层之间不再需要显式的接口依赖。解码层只需要说"我解码失败了",至于谁关心这个事、怎么处理,解码层完全不管。
常用的事件类型:
DecoderErrorEvent— 解码出错BufferingStartEvent/BufferingEndEvent— 缓冲状态变化PlaybackProgressEvent— 播放进度更新StateChangedEvent— 播放器状态切换SeekCompletedEvent— 跳转完成
实现上,我推荐用LiveData或者RxJava的Subject。但要注意:事件总线不要滥用。如果两个模块之间有明确的调用关系,直接用接口反而更清晰。事件总线适合"一对多"或者"不知道谁会关心"的场景。
写在最后
播放器架构设计,说白了就是三件事:分层、状态机、事件总线。
分层让代码不纠缠,状态机让逻辑不乱跑,事件总线让通信不耦合。这三板斧用好了,一个播放器项目再复杂,也能保持清晰。
嗯,我当年第一个播放器项目就是没想清楚这些,结果代码写了一万多行,改个暂停功能都要翻半天。后来重构了一遍,核心代码砍到三千行,反而更稳定了。
有时候,少即是多。
一个小建议:开始写播放器之前,先把状态转换图画出来。画清楚了再动手,能省一半的调试时间。
公众号:蓝海资料掘金营,微信deep3321