19、Seek与随机访问:关键帧定位、精确Seek实现、解码器刷新、音视频同步
Seek操作,说白了就是让视频跳到指定时间点播放。这个功能看起来简单,但坑特别多。我早期做播放器时,以为Seek就是调一下MediaPlayer的seekTo就完事了。结果呢?画面卡住、声音对不上、甚至直接黑屏——嗯,这些都是血泪教训。
今天我们就来拆解一下,Android视频硬解码中Seek到底该怎么实现。我会从关键帧定位讲起,再到精确Seek、解码器刷新,最后搞定音视频同步。
19.1 关键帧定位:为什么不能随便跳?
视频压缩的原理,说白了就是只存变化的部分。I帧是关键帧,存了完整画面。P帧和B帧只存差异信息。所以你想跳到任意时间点,必须从最近的I帧开始解码。
为什么会这样?因为解码器没有「时光机」功能。它只能从I帧开始,一帧一帧往后解。你直接丢一个P帧给它,它根本不知道画面长什么样。
关键帧定位的核心逻辑:
- 解析视频文件的索引表,找到目标时间点之前的最近I帧
- 从该I帧开始解码,丢弃目标时间点之前的帧
- 只保留目标时间点之后的帧用于渲染
我在项目中遇到过一个问题:某些视频文件的索引表不完整,导致MediaExtractor找不到正确的I帧位置。后来我改用MediaMetadataRetriever来辅助定位,才解决了这个问题。
19.2 精确Seek实现:两种模式
Android的MediaExtractor提供了两种Seek模式,我分别说说它们的用法和坑。
| 模式 | 说明 | 适用场景 |
|---|---|---|
| SEEK_TO_PREVIOUS_SYNC | 跳到目标时间点之前的最近关键帧 | 快速Seek,不要求精确到帧 |
| SEEK_TO_CLOSEST_SYNC | 跳到离目标时间点最近的关键帧 | 兼顾速度和精度 |
我个人习惯用SEEK_TO_CLOSEST_SYNC。但要注意,这个模式并不保证精确到毫秒。如果你需要帧级别的精确Seek,那就得自己处理了。
我的经验:精确Seek的实现思路是:先Seek到最近的关键帧,然后逐帧解码直到目标时间点。但这样会消耗额外性能,建议只在用户拖拽进度条时使用。
// 精确Seek示例代码
mediaExtractor.seekTo(targetTimeUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
// 然后逐帧读取,直到时间戳 >= targetTimeUs
while (true) {
int sampleSize = mediaExtractor.readSampleData(byteBuffer, 0);
if (sampleSize < 0) break;
long sampleTime = mediaExtractor.getSampleTime();
if (sampleTime >= targetTimeUs) {
// 找到目标帧,开始解码
break;
}
mediaExtractor.advance();
}
19.3 解码器刷新:别让旧数据污染新画面
Seek之后,解码器里还残留着之前的数据。如果不清理,新旧画面会混在一起,出现花屏或绿屏。这就是为什么需要刷新解码器。
Android的MediaCodec提供了两种刷新方式:
- flush():清空所有输入输出缓冲区,但保留解码器配置
- stop() + start():完全重置解码器,代价较大
我建议用flush()。但要注意,flush之后需要重新配置一些参数,比如CSD(Codec Specific Data)。
警告:我曾经在Seek后忘记重新配置CSD,结果解码器一直输出花屏。排查了半天才发现是这个问题。记住:flush之后,一定要重新设置CSD数据。
// 解码器刷新流程
private void seekAndFlushCodec(long targetTimeUs) {
// 1. 刷新解码器
mediaCodec.flush();
// 2. 重新配置CSD
MediaFormat format = mediaExtractor.getTrackFormat(trackIndex);
byte[] csd0 = format.getByteBuffer("csd-0").array();
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
// 将CSD数据重新提交给解码器
// 3. 定位到目标时间点
mediaExtractor.seekTo(targetTimeUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
}
19.4 音视频同步:Seek后的老大难
Seek之后,音频和视频的时间戳会错位。你想想看,视频跳到了第10秒,音频可能还在第9.5秒。这就是音视频不同步。
解决思路其实不复杂:
- 音频和视频分别Seek到同一个目标时间点
- 以音频时钟为基准,视频帧根据时间戳进行等待或丢弃
- 如果偏差超过阈值(比如100ms),强制同步一次
我在项目中遇到过最头疼的情况:某些视频的音频和视频关键帧位置不一致。视频Seek到了I帧,但音频还在P帧位置。后来我用了「双Seek」策略——音频和视频分别独立Seek,然后以音频时间戳为基准调整视频帧的渲染时间。
音视频同步的核心原则:
- 音频是时钟源,视频跟着音频走
- Seek后先让音频开始播放,视频等待音频稳定后再渲染
- 如果偏差持续扩大,考虑丢帧或重复帧
// Seek后的音视频同步处理
private void syncAfterSeek(long targetTimeUs) {
// 1. 音频Seek并开始播放
audioDecoder.seekTo(targetTimeUs);
audioDecoder.start();
// 2. 视频Seek到相同时间点
videoDecoder.seekTo(targetTimeUs);
// 3. 等待音频稳定,获取当前音频时间戳
long audioTimeUs = audioDecoder.getCurrentPosition();
// 4. 视频帧根据音频时间戳调整渲染
while (videoDecoder.hasMoreFrames()) {
long videoTimeUs = videoDecoder.getFrameTime();
long diff = videoTimeUs - audioTimeUs;
if (diff < -100000) { // 视频落后超过100ms
// 丢弃当前帧,追赶音频
videoDecoder.skipFrame();
} else if (diff > 100000) { // 视频超前超过100ms
// 等待音频追赶
Thread.sleep((diff - 100000) / 1000);
} else {
// 正常渲染
videoDecoder.renderFrame();
}
}
}
19.5 整体流程:一张图看懂
说了这么多,我们来画个流程图,把Seek的完整流程串起来。
这个流程看起来步骤多,但每一步都不能少。我早期做播放器时,跳过了解码器刷新这一步,结果Seek后画面一直花屏。后来老老实实按这个流程走,问题就解决了。
避坑指南:我曾经在Seek后直接开始解码,没有等待音频时钟稳定。结果视频画面忽快忽慢,用户体验极差。后来我加了一个「音频稳定等待」的逻辑——Seek后先让音频跑100ms,再用音频时间戳驱动视频渲染。效果立竿见影。
好了,Seek与随机访问的核心内容就这些。记住:关键帧定位是基础,解码器刷新是保障,音视频同步是灵魂。三者缺一不可。