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请求 关键帧定位:找到最近I帧 解码器刷新:flush() + 重配CSD 精确Seek:逐帧解码到目标时间 音视频同步:音频时钟驱动视频 正常播放

这个流程看起来步骤多,但每一步都不能少。我早期做播放器时,跳过了解码器刷新这一步,结果Seek后画面一直花屏。后来老老实实按这个流程走,问题就解决了。

避坑指南:我曾经在Seek后直接开始解码,没有等待音频时钟稳定。结果视频画面忽快忽慢,用户体验极差。后来我加了一个「音频稳定等待」的逻辑——Seek后先让音频跑100ms,再用音频时间戳驱动视频渲染。效果立竿见影。

好了,Seek与随机访问的核心内容就这些。记住:关键帧定位是基础,解码器刷新是保障,音视频同步是灵魂。三者缺一不可。

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