从零搭建 NDK 视频播放器:先理解流水线、线程和状态机
这篇文章不急着写 AMediaCodec。真正能稳定运行的播放器,第一步不是解码,而是把“谁负责什么、什么时候能做什么、出错后怎么回到安全状态”讲清楚。
如果把播放器比作一个小型工厂,视频文件就是原材料,解复用器是拆箱工人,解码器是加工机器,Surface 是展示窗口,状态机是厂长。没有厂长,工人各干各的,机器偶尔还能转,但只要用户暂停、拖动进度条、切后台,整条流水线就会乱。
先建立直觉:播放器到底在做什么
一个最小视频播放器只做四件事。
视频文件
-> 解复用:从容器里拆出视频压缩帧和音频压缩帧
-> 解码:把压缩帧变成可显示的图像帧或可播放的 PCM
-> 同步:决定这一帧什么时候显示、声音什么时候播放
-> 输出:把视频交给 Surface,把音频交给 AudioTrack/AAudio
这里有几个新术语,先用最普通的话解释。
容器 是文件外壳。MP4、MKV、MOV 都是容器格式,它们像一个文件夹,里面可以同时装视频轨、音频轨、字幕轨、封面图和元信息。
轨道 是容器里的独立数据流。一个 MP4 里通常有一条视频轨和一条音频轨。
压缩帧 是还没有解开的帧。H.264/H.265/AAC 这类编码会把原始图像或声音压小,播放器必须先解码。
PTS 是 Presentation Time Stamp,表示这一帧应该在什么播放时间出现。播放器不是“读到帧就显示”,而是“读到帧后等到它该出现的时候再显示”。
为什么不能一上来写 while 解码循环
初学 NDK 播放器时,很容易写出这种结构。
while (true) {
readPacket();
decodePacket();
renderFrame();
}
这段代码能帮助你验证 API,但它不是播放器架构。原因很简单:真实用户不会只让它从头播到尾。
用户会做这些事。
打开文件
暂停
继续
拖动进度条
横竖屏切换
切到后台
回来
关闭页面
快速重复点击
这些动作发生时,解复用线程、解码器、Surface、音频输出都可能正处于不同状态。没有状态机,代码只能靠大量 if 判断硬撑,最后出现黑屏、旧帧闪回、线程无法退出、native crash。
播放器要拆成四个平面
控制平面
控制平面只处理用户命令和生命周期,不直接读文件、不直接解码。
open / play / pause / seek / stop / release
控制平面的核心是状态机。它决定当前命令是否合法,以及命令应该交给哪条工作线程。
数据平面
数据平面处理真正的数据流。
DemuxEngine -> PacketQueue -> DecodeEngine -> FrameQueue -> RenderEngine
DemuxEngine 只负责拆容器。
DecodeEngine 只负责喂 codec 和取输出。
RenderEngine 只负责按照时钟把帧交给 Surface。
时钟平面
时钟平面决定“什么时候播放”。视频帧和音频帧不能各走各的,必须有一个主时钟。
本系列先采用最常见策略:音频时钟作为主时钟,视频追随音频。
观测平面
观测平面负责日志和指标。播放器问题非常依赖现场证据,不能只打印一句 decode failed。
建议最少记录这些指标。
state
command_serial
thread_name
packet_queue_size
frame_queue_size
video_pts_us
audio_clock_us
av_drift_us
first_frame_ms
seek_cost_ms
最小状态机
先从一个保守状态机开始。
Idle
-> Open
Preparing
-> Prepared
Ready
-> Play
Playing
-> Pause
Paused
-> Play
Playing
-> Seek
Seeking
-> Prepared
Ready
-> Play
Playing
-> EndOfStream
Ended
任意状态
-> Error
-> Release
-> Released
把它翻译成 C++ 枚举。
enum class PlayerState {
Idle,
Preparing,
Ready,
Playing,
Paused,
Buffering,
Seeking,
Ended,
Error,
Releasing,
Released,
};
enum class PlayerCommandType {
Open,
Prepared,
Play,
Pause,
Seek,
EndOfStream,
Fail,
Release,
};
struct PlayerCommand {
PlayerCommandType type;
int64_t argumentUs;
int64_t serial;
};
serial 是命令序号。它的作用像排队号码:后来的 seek 不能被先来的 seek 覆盖。
命令队列:把 UI 操作变成有序事件
Kotlin 层不要直接调用 nativePlay()、nativePause() 后马上改状态。更稳的方式是把命令放进 native 命令队列,由控制线程统一消费。
class CommandQueue {
public:
void push(PlayerCommand command) {
std::lock_guard<std::mutex> lock(mutex_);
commands_.push(command);
cv_.notify_one();
}
bool pop(PlayerCommand* out) {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [&] { return !commands_.empty() || stopped_; });
if (commands_.empty()) return false;
*out = commands_.front();
commands_.pop();
return true;
}
void stop() {
std::lock_guard<std::mutex> lock(mutex_);
stopped_ = true;
cv_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<PlayerCommand> commands_;
bool stopped_ = false;
};
这段代码的重点不是队列本身,而是它把“任意线程来的 UI 操作”变成“控制线程上的顺序事件”。播放器的稳定性从这里开始。
控制线程:只改状态,不做重活
class PlayerController {
public:
void loop() {
PlayerCommand command{};
while (queue_.pop(&command)) {
handle(command);
if (state_ == PlayerState::Released) break;
}
}
private:
void handle(const PlayerCommand& command) {
switch (command.type) {
case PlayerCommandType::Open:
if (state_ == PlayerState::Idle) enterPreparing(command);
break;
case PlayerCommandType::Play:
if (state_ == PlayerState::Ready || state_ == PlayerState::Paused) enterPlaying(command);
break;
case PlayerCommandType::Pause:
if (state_ == PlayerState::Playing) enterPaused(command);
break;
case PlayerCommandType::Seek:
if (state_ == PlayerState::Playing || state_ == PlayerState::Paused) enterSeeking(command);
break;
case PlayerCommandType::Release:
enterReleasing(command);
break;
default:
break;
}
}
PlayerState state_ = PlayerState::Idle;
CommandQueue queue_;
};
注意这里没有 AMediaExtractor_readSampleData,也没有 AMediaCodec_dequeueInputBuffer。控制线程只负责调度,重活交给工作线程。
工作线程边界
建议最少三条工作线程。
demuxThread:读取容器,产出压缩包
decodeThread:消费压缩包,产出解码结果
renderThread:按时钟提交视频帧
音频可以先放在 Kotlin AudioTrack 层实现,等视频主链路稳定后再迁移到 AAudio 或 Oboe。这样对初学者更友好,也更容易定位问题。
生命周期最容易出错的地方
Surface 是 Android 图形系统管理的显示目标,不是 C++ 里的普通对象。Activity 退后台、旋转、销毁时,Surface 可能比 native 播放器先消失。
所以控制层必须把 Surface 生命周期也变成命令。
surfaceCreated -> AttachSurface
surfaceDestroyed -> DetachSurface
activityDestroy -> Release
不要在 surfaceDestroyed 里直接 delete codec,也不要在 render 线程里偷偷持有旧窗口。
初学者最小实验
第一步只实现状态机,不接任何媒体 API。
Open -> Prepared -> Play -> Pause -> Play -> Seek -> Prepared -> Play -> Release
你应该能在日志里看到完整状态变化。
Idle -> Preparing
Preparing -> Ready
Ready -> Playing
Playing -> Paused
Paused -> Playing
Playing -> Seeking
Seeking -> Ready
Ready -> Playing
Playing -> Releasing -> Released
第二步接入假帧。每 33ms 产生一帧,模拟 30fps。
第三步才接入真实 AMediaExtractor 和 AMediaCodec。
工程验收标准
一个播放器骨架是否合格,看这些指标。
release 调用后线程能退出
快速 play/pause 不会出现非法状态
连续 seek 只执行最后一次有效 seek
Surface 销毁后 render 不再提交旧窗口
错误事件能带上 state、serial、thread、source
本章结论
NDK 播放器不是 API 拼装题,而是一个并发系统。先把状态机、命令队列、线程边界和生命周期做实,后面写解复用和解码时才不会被偶发时序问题拖住。