26、播放器架构设计:播放器分层架构、状态管理、事件总线设计
做播放器开发这几年,我踩过最大的坑就是——架构没想清楚就动手写代码。
记得我第一次独立负责播放器模块时,上来就写了个巨大的 PlayerManager 类,里面塞满了初始化、解码、渲染、状态切换、事件回调……结果呢?改一个 bug 引出三个新 bug,状态乱跳,事件满天飞。后来我花了整整两周重构,才把架构理清楚。
今天这一讲,我就把当时总结出来的经验分享给你。说白了,就是三件事:怎么分层、怎么管状态、怎么传事件。
一、播放器分层架构
我习惯把播放器拆成四层,每层各司其职。你想想看,如果所有逻辑都揉在一起,调试起来得多痛苦?
| 层级 | 职责 | 核心组件 |
|---|---|---|
| UI 层 | 用户交互、画面显示 | SurfaceView / TextureView、控制条 |
| 业务层 | 播放控制、状态管理、事件分发 | PlayerController、StateMachine |
| 核心层 | 解码、渲染、音视频同步 | MediaCodec、AudioTrack、Renderer |
| 数据层 | 数据源读取、缓存、协议解析 | DataSource、Extractor、CacheManager |
每一层只依赖它的下一层,不能跨层调用。举个例子:UI 层想暂停播放,它不会直接去调核心层的解码器,而是通过业务层的 PlayerController.pause() 来触发。
核心原则:上层只管「做什么」,下层只管「怎么做」。业务层是中间人,负责协调。
我在项目中遇到过一个问题:UI 层直接调用了核心层的 flushDecoder(),结果状态没同步,导致画面卡死。从那以后,我强制要求所有跨层调用必须经过业务层。
1.1 各层详细说明
UI 层:说白了就是用户能看到的部分。SurfaceView 负责显示画面,控制条负责交互。这一层尽量薄,不要放业务逻辑。
业务层:这是播放器的「大脑」。它管理着播放状态(播放、暂停、缓冲、结束),还负责把 UI 层的操作翻译成核心层的指令。我习惯在这里放一个 StateMachine,专门处理状态流转。
核心层:解码和渲染的苦力活都在这里。MediaCodec 解码视频帧,AudioTrack 播放音频,Renderer 负责音视频同步。嗯,这里要注意,音视频同步的算法很容易出问题,我后面会专门讲。
数据层:从网络或本地读取数据,解析封装格式(MP4、TS、FLV 等),并提供缓存能力。这一层如果做得好,可以大大减少卡顿。
我的习惯:每一层都定义一个接口,实现类可以随时替换。比如数据层,你可以先写一个本地文件实现,后面再换成网络实现,业务层完全不用改。
二、状态管理
播放器的状态管理,是很多新手翻车的地方。为什么?因为状态太多了:空闲、初始化、准备中、准备就绪、播放中、暂停、缓冲、结束、错误……而且状态之间还有严格的转换规则。
我曾经在一个项目里,直接用 int 常量表示状态,然后在各个回调里手动判断。结果呢?状态乱跳,比如在「缓冲」状态下收到了「暂停」指令,播放器直接崩溃了。
后来我改用有限状态机,才彻底解决了这个问题。
2.1 状态定义
我一般定义这几个核心状态:
- IDLE:初始状态,播放器刚创建
- INITIALIZED:数据源已设置
- PREPARING:正在准备(异步)
- PREPARED:准备完成,可以播放
- PLAYING:正在播放
- PAUSED:暂停
- BUFFERING:缓冲中
- ENDED:播放结束
- ERROR:出错
- RELEASED:已释放
2.2 状态转换规则
不是所有状态都能互相转换。比如从 PAUSED 可以直接到 PLAYING,但从 ERROR 只能到 IDLE(重新初始化)。
我画了一张状态转换图,你看一眼就明白了:
你看,从 PLAYING 可以到 PAUSED、BUFFERING、ENDED,但不能直接到 PREPARED。如果状态机不允许,调用方再怎么乱调也没用。
2.3 代码实现要点
// 状态机核心逻辑
public class PlayerStateMachine {
private PlayerState currentState = PlayerState.IDLE;
public void transition(PlayerEvent event) {
PlayerState nextState = getNextState(currentState, event);
if (nextState == null) {
throw new IllegalStateException(
"Cannot transition from " + currentState + " with event " + event);
}
currentState = nextState;
// 通知状态变化
notifyStateChanged(currentState);
}
private PlayerState getNextState(PlayerState state, PlayerEvent event) {
// 这里用二维数组或 Map 维护状态转换表
// 比如 state=PLAYING, event=PAUSE -> 返回 PAUSED
// 如果转换非法,返回 null
}
}
注意:状态转换一定要是原子操作。不要在转换过程中插入其他逻辑,否则容易出现竞态条件。我建议用单线程的 Handler 来驱动状态机。
三、事件总线设计
播放器内部各个模块之间需要通信。比如解码器解码完一帧,要通知渲染器去渲染;网络模块缓冲够了,要通知播放器从 BUFFERING 切回 PLAYING。
如果直接用回调接口,模块之间会耦合得很紧。我习惯用事件总线来解耦。
3.1 事件分类
我把事件分成三类:
| 事件类型 | 示例 | 发送者 | 接收者 |
|---|---|---|---|
| 状态事件 | STATE_CHANGED | 状态机 | 业务层、UI 层 |
| 数据事件 | BUFFER_UPDATE | 数据层 | 业务层 |
| 错误事件 | ERROR_OCCURRED | 任意层 | 业务层、UI 层 |
事件总线本身很简单,就是一个发布-订阅模式:
public class EventBus {
private final Map<Class<?>, List<EventListener>> listeners = new HashMap<>();
public void register(Class<?> eventType, EventListener listener) {
listeners.computeIfAbsent(eventType, k -> new ArrayList<>()).add(listener);
}
public void unregister(Class<?> eventType, EventListener listener) {
List<EventListener> list = listeners.get(eventType);
if (list != null) list.remove(listener);
}
public void post(Object event) {
List<EventListener> list = listeners.get(event.getClass());
if (list != null) {
for (EventListener listener : list) {
listener.onEvent(event);
}
}
}
}
3.2 事件总线的坑
嗯,这里我要说一个我踩过的坑。事件总线如果用不好,会导致内存泄漏和事件风暴。
- 内存泄漏:注册了监听器,但忘记在销毁时取消注册。我建议在 BasePlayer 的
release()方法里统一清理所有监听器。 - 事件风暴:一个事件触发另一个事件,形成循环。比如 BUFFER_UPDATE 事件又触发了状态切换,状态切换又触发了 BUFFER_UPDATE……我曾经被这个坑过,调试了一下午才发现是事件循环。
我的解决方案:在事件总线里加一个深度计数器,如果同一事件的递归深度超过 3,直接抛异常。这样能快速发现循环依赖。
3.3 事件总线的替代方案
如果你觉得事件总线太「重」,也可以用 LiveData 或 Flow 来替代。我个人习惯在播放器内部用事件总线,因为它的语义更清晰——事件是一次性的,不像 LiveData 会粘性传递。
举个例子:播放器从 BUFFERING 切到 PLAYING,这个事件只需要通知一次。如果用 LiveData,新注册的观察者会收到最后一次的状态,这显然不对。
四、整体架构图
最后,我把整个播放器的架构画成一张图,方便你理解各层之间的关系:
你看,控制流是自上而下的,UI 层通过业务层调用核心层和数据层。而事件流是横向的,通过事件总线在各个层之间传递。
这样做的好处是:每一层都可以独立测试。我经常在写业务层之前,先 mock 一个核心层,把状态机和事件总线调通,再往下做。
总结一下:
- 分层架构让职责清晰,每层只关心自己的事
- 状态机让状态流转可控,不会出现非法状态
- 事件总线让模块解耦,通信灵活
这三样东西组合起来,就是一个健壮、可扩展的播放器骨架。后面几讲,我们会在这个骨架上填充血肉——解码、渲染、音视频同步,一步步把它变成一个完整的播放器。