Seek、缓冲与故障恢复:让播放器经得起用户反复操作
播放器从“能播”到“能用”,差距主要在交互。用户拖动进度条、反复暂停继续、切后台回来,这些动作会打断原来的解复用、解码、渲染节奏。
这一章解决三个问题。
seek 后为什么容易黑屏或花屏
缓冲为什么不能只看队列是否为空
错误恢复为什么要分可恢复和不可恢复
Seek 不是跳到某一帧那么简单
先理解视频压缩。视频不是每一帧都完整保存。
I 帧:完整图像,像一张独立照片
P 帧:参考之前的帧,只保存变化
B 帧:可能参考前后帧,只保存变化
如果你直接跳到 P 帧,解码器可能缺少它依赖的上一帧。结果就是花屏、黑屏、或者 decode error。
所以 seek 通常要落到目标时间之前最近的关键帧。
AMediaExtractor_seekTo(
extractor,
targetUs,
AMEDIAEXTRACTOR_SEEK_PREVIOUS_SYNC
);
Seek 的正确顺序
一次稳定 seek 应该像一次小型重建。
收到 Seek 命令
生成新的 seek serial
暂停 demux 读取
清空 packet 队列和 frame 队列
extractor seek 到关键帧
codec flush
重置时钟基准
重新填充队列
输出第一帧
状态回到 Playing 或 Paused
代码骨架如下。
class SeekController {
public:
int64_t nextSerial() {
return ++serial_;
}
bool isCurrent(int64_t serial) const {
return serial == serial_.load();
}
private:
std::atomic<int64_t> serial_{0};
};
void performSeek(int64_t targetUs, int64_t serial) {
packetQueue_.flush();
frameQueue_.flush();
AMediaExtractor_seekTo(
extractor_,
targetUs,
AMEDIAEXTRACTOR_SEEK_PREVIOUS_SYNC
);
AMediaCodec_flush(videoCodec_);
clock_.resetTo(targetUs);
if (!seekController_.isCurrent(serial)) {
return;
}
state_ = PlayerState::Ready;
}
serial 的作用是防止旧 seek 覆盖新 seek。用户快速拖进度条时,这个机制非常重要。
为什么 codec 要 flush
codec 内部有输入队列和输出队列。seek 之后,旧时间线里的 buffer 不能继续使用。
如果只 seek extractor,不 flush codec,可能出现这种情况。
extractor 已经跳到 60s
codec 里还有 10s 的旧输出帧
render 线程先显示旧帧
用户看到画面闪回
AMediaCodec_flush 的语义就是清理 codec 当前积压的数据,让你从新的输入重新开始。官方文档还特别说明:异步 callback 模式下 flush 后需要重新 start 才能继续接收输入回调。同步 dequeue 模式比异步模式更适合初学阶段。
缓冲不是越多越好
缓冲像水箱。太少会断流,太多会占内存,还会让 seek 变慢。
建议用双阈值。
低水位:低于这个值,进入 Buffering
高水位:高于这个值,恢复 Playing
struct BufferPolicy {
size_t lowWaterPackets = 4;
size_t highWaterPackets = 24;
};
PlayerState evaluateBuffer(size_t packetCount, const BufferPolicy& policy) {
if (packetCount < policy.lowWaterPackets) {
return PlayerState::Buffering;
}
if (packetCount >= policy.highWaterPackets) {
return PlayerState::Playing;
}
return PlayerState::Buffering;
}
为什么要有两个阈值,而不是一个阈值?因为只有一个阈值时,队列在临界点附近来回波动,状态会频繁在 Buffering 和 Playing 间抖动。
错误要分类
不是所有错误都应该让播放器直接失败。
短暂读不到数据:可恢复
偶发坏包:可恢复
Surface 已销毁:需要等待新 Surface
codec 创建失败:大概率不可恢复
文件格式不支持:不可恢复
定义错误等级。
enum class ErrorClass {
Recoverable,
Fatal,
};
struct PlayerError {
ErrorClass klass;
int code;
const char* source;
const char* message;
};
控制层收到 Recoverable 时可以进入 Buffering 或重建局部组件。收到 Fatal 时进入 Error,等待用户关闭或重新打开。
可恢复错误的重试策略
重试不能无限进行。无限重试会遮住真实问题。
class RetryBudget {
public:
explicit RetryBudget(int maxAttempts) : maxAttempts_(maxAttempts) {}
bool consume() {
if (attempts_ >= maxAttempts_) return false;
++attempts_;
return true;
}
void reset() {
attempts_ = 0;
}
private:
int maxAttempts_ = 0;
int attempts_ = 0;
};
可以按组件设置预算。
demux transient error:3 次
decode dequeue timeout:5 次
surface unavailable:等待生命周期事件
unsupported format:0 次
本章实验
实验一:快速 seek。
5 秒内拖动 20 次
最终画面应停在最后一次 seek 位置
中间旧 seek 不应覆盖新 seek
实验二:seek 后清队列。
seek 前 packet ptsUs 在 10s 附近
seek 后 packet ptsUs 在目标关键帧附近
不允许出现 10s 和 60s 的 packet 交错
实验三:缓冲抖动。
人为降低 demux 速度
观察 Buffering/Playing 是否频繁切换
调整 lowWater/highWater 直到状态稳定
本章结论
Seek 的本质是一次时间线重建,缓冲的本质是水位控制,错误恢复的本质是分类决策。把这三件事分清,播放器就能经得起用户真实操作。