音视频同步与渲染:PTS、VSYNC 与 SurfaceFlinger 的协作之道
你应该有过这种体验:看一段讲课视频,口型和声音差了半秒,难受到想关掉。或者打王者荣耀直播,英雄死了一秒后才听到"一血"的提示音。
这些体验的背后,都是音视频同步出了问题。
Android 的视频子系统每秒要处理 30 到 120 帧的视频,同时还要精确控制音频输出的时机,确保两者在时间上对齐到几十毫秒的精度。这不是一件容易的事——尤其是视频帧的解码时间本身就是不均匀的,网络传输的抖动更会带来不可预测的延迟。
本文是「Android 15 视频子系统」系列的收官篇,我们来把整个渲染链路从头捋一遍:从 PTS 时间戳的生成,到 SurfaceFlinger 的 VSYNC 驱动合成,把"为什么音视频会不同步"和"如何让它们同步"讲清楚。
为什么音视频同步是个难题
在理想世界里,音频和视频都从同一个时间轴上取数据,同时解码,同时输出,天下太平。
现实世界里:
视频帧 #100,编码时间: 3330ms → 解码耗时: 8ms(I帧)或 2ms(P帧) → 等待 Output Buffer: 可能额外延迟 5~20ms → 渲染到屏幕:下一个 VSYNC 时机 音频帧 #100,编码时间: 3330ms → 解码耗时: < 1ms(AAC 软解) → AudioFlinger ring buffer 延迟: 20~60ms → 扬声器实际播放: 约 40ms 后两者从"编码完成"到"用户感知",经历了完全不同的路径,自然会有偏差。没有主动的同步机制,音画错位是必然的。
Android 的解法很简单粗暴:以音频输出时间为绝对基准,视频帧主动向音频时钟对齐。
PTS:时间戳的生命旅程
什么是 PTS
PTS(Presentation Time Stamp,显示时间戳)是容器格式(MP4、MKV 等)为每一帧数据打上的时间标记,单位通常是微秒(µs)或时间基(time base)。
它回答了一个问题:这一帧应该在什么时刻展示给用户?
注意区分:
- DTS(Decoding Time Stamp):解码时间戳——视频压缩中 B 帧的解码顺序与展示顺序不同,DTS 记录解码顺序
- PTS(Presentation Time Stamp):显示时间戳——无论解码顺序如何,最终显示给用户的顺序
对于音频,PTS 和 DTS 通常相同(音频没有 B 帧的概念)。
PTS 从容器到屏幕的旅程
MP4 容器 → MediaExtractor.readSampleData() → ABuffer.meta()->setInt64("timeUs", pts) ← PTS 附在数据包上 → NuPlayer::Decoder Input Buffer → MediaCodec.queueInputBuffer(timeUs = pts) ← 传给硬件解码器 → MediaCodec.dequeueOutputBuffer(info.presentationTimeUs) ← 从解码器取出 → NuPlayer::Renderer 视频队列 → 与音频时钟对比 → 决定渲染时机 → releaseOutputBuffer(render=true) → Surface关键一点:PTS 在整个链路中全程跟随数据包,不在中途重新计算。这意味着只要 Renderer 能拿到正确的 PTS,它就能知道这帧该在什么时候显示。
PTS 精度问题
MediaExtractor 读出的 PTS 精度依赖容器格式:
// MP4 的时间基通常是 1/timescale// 如果 timescale = 90000(常见值),则精度 ≈ 11µs// 如果 timescale = 1000,则精度只有 1ms// MediaExtractor 会自动换算为微秒longsampleTimeUs=extractor.getSampleTime();// 单位: µs90000 这个时间基是 MPEG 的"老规矩",能被常见帧率(24/25/30/50/60fps)整除。如果你见到 PTS 不是 33333µs 的整数倍,但帧率是 30fps,通常是因为时间基精度问题,不是 bug。
音视频时钟同步
三种时钟源
NuPlayer 的 Renderer 在内部维护了一个MediaClock,可以使用三种时钟源:
| 时钟源 | 优先级 | 精度 | 适用场景 |
|---|---|---|---|
| 音频时钟(AudioTrack.getTimestamp) | 最高 | ~1ms | 正常含音频播放 |
| 系统时钟(CLOCK_MONOTONIC) | 中 | 亚微秒 | 无音频/音频暂停 |
| 视频时钟(估算) | 最低 | 较差 | 降级场景 |
// frameworks/av/media/libstagefright/MediaClock.cppint64_tMediaClock::getMediaTime(int64_trealUs)const{if(mAnchorTimeMediaUs<0){return-1;// 时钟未启动}// 核心公式:媒体时间 = 锚点 + (实际流逝时间 × 播放速率)int64_tmediaUs=mAnchorTimeMediaUs+(realUs-mAnchorTimeRealUs)*(double)mPlaybackRate;returnmediaUs;}音频时钟的获取
这是精度最高的路径,也是默认路径:
// Renderer 从 AudioTrack 获取精确时钟boolNuPlayer::Renderer::getAnchorTime(int64_t*mediaUs,int64_t*realUs){AudioTimestamp ts;if(mAudioSink->getTimestamp(ts)==OK){// mPosition: AudioTrack 已播放的帧数// mTime: 对应的系统时间(来自 AudioFlinger)int64_tplayedFrames=ts.mPosition;int64_tplayedUs=playedFrames*1000000LL/mAudioSampleRate;// 把"已播放帧数对应的媒体时间"和"那个时刻的系统时间"记录下来// 作为锚点,之后用实时系统时钟推算当前媒体时间*mediaUs=mAnchorStartMediaUs+playedUs;*realUs=convertTimespecToUs(ts.mTime);returntrue;}returnfalse;}AudioTimestamp的精度来自内核的音频硬件时间戳,通常在 1ms 以内,远比轮询系统时钟更准确。这就是为什么音频时钟是首选。
同步仲裁:Renderer 的核心决策
音视频同步的核心逻辑在NuPlayer::Renderer::onDrainVideoQueue():
voidNuPlayer::Renderer::onDrainVideoQueue(){QueueEntry&entry=*mVideoQueue.begin();// 从 MediaCodec Output Buffer 取出 PTSint64_tvideoPtsUs=entry.mTimeUs;// 当前音频时钟位置(换算到媒体时间)int64_tnowMediaUs=-1