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(重新初始化)。

我画了一张状态转换图,你看一眼就明白了:

IDLE INITIALIZED PREPARING PREPARED PLAYING PAUSED BUFFERING ENDED ERROR RELEASED reset() release()

你看,从 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 事件总线的替代方案

如果你觉得事件总线太「重」,也可以用 LiveDataFlow 来替代。我个人习惯在播放器内部用事件总线,因为它的语义更清晰——事件是一次性的,不像 LiveData 会粘性传递。

举个例子:播放器从 BUFFERING 切到 PLAYING,这个事件只需要通知一次。如果用 LiveData,新注册的观察者会收到最后一次的状态,这显然不对。

四、整体架构图

最后,我把整个播放器的架构画成一张图,方便你理解各层之间的关系:

UI 层 SurfaceView / TextureView / 控制条 业务层 PlayerController / StateMachine / EventBus 核心层 MediaCodec / AudioTrack / Renderer / AVSync 数据层 DataSource / Extractor / CacheManager 事件总线 控制流 ↓ 事件流 →

你看,控制流是自上而下的,UI 层通过业务层调用核心层和数据层。而事件流是横向的,通过事件总线在各个层之间传递。

这样做的好处是:每一层都可以独立测试。我经常在写业务层之前,先 mock 一个核心层,把状态机和事件总线调通,再往下做。

总结一下:

  • 分层架构让职责清晰,每层只关心自己的事
  • 状态机让状态流转可控,不会出现非法状态
  • 事件总线让模块解耦,通信灵活

这三样东西组合起来,就是一个健壮、可扩展的播放器骨架。后面几讲,我们会在这个骨架上填充血肉——解码、渲染、音视频同步,一步步把它变成一个完整的播放器。


公众号:蓝海资料掘金营,微信deep3321