你应该有过这种体验:看一段讲课视频,口型和声音差了半秒,难受到想关掉。或者打王者荣耀直播,英雄死了一秒后才听到"一血"的提示音。
这些体验的背后,都是音视频同步出了问题。
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 会自动换算为微秒
long sampleTimeUs = extractor.getSampleTime(); // 单位: µs
90000 这个时间基是 MPEG 的"老规矩",能被常见帧率(24/25/30/50/60fps)整除。如果你见到 PTS 不是 33333µs 的整数倍,但帧率是 30fps,通常是因为时间基精度问题,不是 bug。
音视频时钟同步
三种时钟源
NuPlayer 的 Renderer 在内部维护了一个 MediaClock,可以使用三种时钟源:
| 时钟源 | 优先级 | 精度 | 适用场景 |
|---|---|---|---|
| 音频时钟(AudioTrack.getTimestamp) | 最高 | ~1ms | 正常含音频播放 |
| 系统时钟(CLOCK_MONOTONIC) | 中 | 亚微秒 | 无音频/音频暂停 |
| 视频时钟(估算) | 最低 | 较差 | 降级场景 |
// frameworks/av/media/libstagefright/MediaClock.cpp
int64_t MediaClock::getMediaTime(int64_t realUs) const {
if (mAnchorTimeMediaUs < 0) {
return -1; // 时钟未启动
}
// 核心公式:媒体时间 = 锚点 + (实际流逝时间 × 播放速率)
int64_t mediaUs = mAnchorTimeMediaUs +
(realUs - mAnchorTimeRealUs) * (double)mPlaybackRate;
return mediaUs;
}
音频时钟的获取
这是精度最高的路径,也是默认路径:
// Renderer 从 AudioTrack 获取精确时钟
bool NuPlayer::Renderer::getAnchorTime(int64_t *mediaUs, int64_t *realUs) {
AudioTimestamp ts;
if (mAudioSink->getTimestamp(ts) == OK) {
// mPosition: AudioTrack 已播放的帧数
// mTime: 对应的系统时间(来自 AudioFlinger)
int64_t playedFrames = ts.mPosition;
int64_t playedUs = playedFrames * 1000000LL / mAudioSampleRate;
// 把"已播放帧数对应的媒体时间"和"那个时刻的系统时间"记录下来
// 作为锚点,之后用实时系统时钟推算当前媒体时间
*mediaUs = mAnchorStartMediaUs + playedUs;
*realUs = convertTimespecToUs(ts.mTime);
return true;
}
return false;
}
AudioTimestamp 的精度来自内核的音频硬件时间戳,通常在 1ms 以内,远比轮询系统时钟更准确。这就是为什么音频时钟是首选。
同步仲裁:Renderer 的核心决策
音视频同步的核心逻辑在 NuPlayer::Renderer::onDrainVideoQueue():
void NuPlayer::Renderer::onDrainVideoQueue() {
QueueEntry &entry = *mVideoQueue.begin();
// 从 MediaCodec Output Buffer 取出 PTS
int64_t videoPtsUs = entry.mTimeUs;
// 当前音频时钟位置(换算到媒体时间)
int64_t nowMediaUs = -1;
if (!getAnchorTime(nullptr, nullptr)) {
// 音频时钟还没建立(刚开始播放),用系统时钟
nowMediaUs = ALooper::GetNowUs() - mAnchorTimeRealUs
+ mAnchorTimeMediaUs;
} else {
nowMediaUs = getCurrentMediaTime(ALooper::GetNowUs());
}
// 计算视频帧与音频时钟的偏差
// 正值 = 视频落后音频(需要加速/丢帧)
// 负值 = 视频超前音频(需要等待)
int64_t lateByUs = nowMediaUs - videoPtsUs;
if (lateByUs > kTooLateThresholdUs) {
// 落后超过 40ms:丢帧!宁可跳帧也不卡顿
mCodec->releaseOutputBuffer(entry.mBufferIx, false);
mVideoQueue.erase(mVideoQueue.begin());
++mNumFramesDropped;
ALOGV("[video] late by %.2f ms, dropping frame",
lateByUs / 1E3);
return;
}
if (lateByUs < -kEarlyThresholdUs) {
// 超前超过 30ms:还没到时候,重新调度
sp<AMessage> msg = new AMessage(kWhatDrainVideoQueue, this);
// 延迟投递:等到快要到时间再检查
msg->post(-lateByUs - kEarlyThresholdUs);
return;
}
// 处于 [-30ms, +40ms] 窗口内:立即渲染
mCodec->releaseOutputBuffer(entry.mBufferIx, true /* render */);
mVideoQueue.erase(mVideoQueue.begin());
++mNumFramesRendered;
}
// 阈值定义
static const int64_t kTooLateThresholdUs = 40000LL; // 40ms
static const int64_t kEarlyThresholdUs = 30000LL; // 30ms
这 70ms 的窗口(-30ms 到 +40ms)是经验值:人类视觉对视频超前声音的容忍度比滞后更低,所以允许视频落后的空间(40ms)大于超前的空间(30ms)。
Surface 渲染 Pipeline
从 releaseOutputBuffer 到像素点亮
当 Renderer 调用 releaseOutputBuffer(bufferIx, true) 时,一帧视频开始了它的最后一段旅程:
MediaCodec.releaseOutputBuffer(true)
│
▼ GraphicBuffer 句柄(零拷贝)
Surface.queueBuffer()
│
▼ BufferQueue(生产者侧)
SurfaceFlinger 接收信号
│
▼ 等待下一个 VSYNC
Layer 合成(GPU or HWC)
│
▼ Framebuffer
Display Controller
│
▼ 屏幕上的像素发光
整个过程没有一次内存拷贝:HW Decoder 解码输出的 GraphicBuffer,通过 BufferQueue 的 slot 机制,被 SurfaceFlinger 直接取走合成,零拷贝。
BufferQueue:生产者与消费者
BufferQueue 是 Android 图形系统的核心抽象,连接"生产图像的人"和"消费图像的人":
生产者(Decoder/App) 消费者(SurfaceFlinger)
─────────────────────────────────────────────────────
1. dequeueBuffer(slot) 2. acquireBuffer(slot)
获取空闲的 GraphicBuffer槽 取出已填充的槽
3. 填充解码数据(VPU 直写) 4. 合成到屏幕
5. queueBuffer(slot) 6. releaseBuffer(slot)
标记为"已就绪" 归还槽,供生产者复用
槽(slot)的数量通常是 2 个(double buffer)或 3 个(triple buffer):
- Double Buffer(2 槽):内存占用小,但如果 SurfaceFlinger 没及时消费,生产者会在
queueBuffer时阻塞,可能导致掉帧 - Triple Buffer(3 槽):多一个备用槽,让生产者和消费者之间有更多调度余地,以内存换流畅度
Android 9+ 默认使用 triple buffer。
SurfaceFlinger:图层的幕后导演
SurfaceFlinger 的核心工作是在每个 VSYNC 信号到来时,把所有应用提交的图层合成为一帧送给显示控制器。
合成方式有两种:
GPU Composition(软件合成):
- 用 OpenGL ES 把所有图层绘制到一个 Framebuffer
- 支持任意旋转、缩放、Alpha 混合
- 消耗 GPU 资源,产生额外延迟(GPU 处理时间)
HWC Composition(硬件合成):
- Hardware Composer HAL 直接控制显示控制器的 overlay 层
- 不走 GPU,延迟更低,更省电
- 有数量限制(通常 4~8 个 overlay 层)
- 全屏视频播放走这条路
// SurfaceFlinger 决策示意(简化)
void SurfaceFlinger::composeLayers() {
// 询问 HWC 能处理哪些图层
hwc2_error_t err = mHwcDevice->validateDisplay(hwcDisplay, ...);
for (auto& layer : layers) {
if (layer->getCompositionType() == HWC2::Composition::Client) {
// HWC 拒绝处理,走 GPU 合成
renderLayerWithGpu(layer);
}
// else: HWC Overlay 直接处理,不需要 CPU/GPU 介入
}
// 提交给 HWC,等待 VSYNC 后输出
mHwcDevice->presentDisplay(hwcDisplay, ...);
}
VSYNC 与 Choreographer
为什么需要 VSYNC
屏幕的刷新是周期性的,60Hz 的屏幕每隔 16.67ms 扫描一次。如果你在屏幕扫描到一半时更新了图像内容,就会出现画面撕裂(Tearing)——屏幕上半部分是旧帧,下半部分是新帧。
VSYNC(Vertical Synchronization)信号就是屏幕每次刷新时发出的同步脉冲,所有图像更新都应该与这个脉冲同步。
Android 的 VSYNC 分发体系
Android 4.1(Project Butter)引入了 Choreographer 框架,把 VSYNC 信号精确分发给需要的组件:
屏幕硬件 → HW VSYNC (16.67ms)
│
▼
DispSync (软件 PLL)
├── SF VSYNC → SurfaceFlinger (合成时机)
└── App VSYNC → Choreographer (动画时机)
│
▼
View.invalidate() 触发重绘
MediaCodec.releaseOutputBuffer() 触发渲染
Animator.update() 更新动画
SF VSYNC 通常比 App VSYNC 提前约一个合成时间(4~6ms),给 SurfaceFlinger 留足合成时间。
Choreographer 在视频渲染中的作用
对于视频播放,Choreographer 的直接作用是:让 App 知道屏幕刷新的时机,从而在正确的时刻投递下一帧。
// 如果需要在应用层精确控制视频帧投递时机
class VideoSurface(context: Context) : View(context) {
private val choreographer = Choreographer.getInstance()
private val frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
// frameTimeNanos 是这个 VSYNC 的时间戳(纳秒)
val frameTimeUs = frameTimeNanos / 1000L
// 检查是否有应该在这个时机渲染的视频帧
checkAndRenderFrame(frameTimeUs)
// 注册下一帧回调
choreographer.postFrameCallback(this.frameCallback)
}
fun start() {
choreographer.postFrameCallback(frameCallback)
}
private fun checkAndRenderFrame(vsyncTimeUs: Long) {
val nextFrame = videoFrameQueue.peek() ?: return
// 如果帧的 PTS 在这个 VSYNC 窗口内,就渲染
if (nextFrame.ptsUs <= vsyncTimeUs + VSYNC_TOLERANCE_US) {
val frame = videoFrameQueue.poll()
surface.attachFrame(frame)
}
}
}
NuPlayer 的 Renderer 内部做的事与此类似,但集成在 ALooper 消息循环里,而不是直接使用 Choreographer API。
深入 AudioTrack 时间戳
为什么 AudioTrack.getTimestamp 如此重要
前面多次提到"音频时钟",它的精确性来源是 AudioTrack.getTimestamp():
val audioTrack = AudioTrack.Builder()
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(44100)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build())
.build()
audioTrack.play()
// 获取精确时间戳
val timestamp = AudioTimestamp()
if (audioTrack.getTimestamp(timestamp)) {
// timestamp.framePosition: 已经"从扬声器出来"的帧数
// timestamp.nanoTime: 对应的系统时间(CLOCK_MONOTONIC)
val playedUs = timestamp.framePosition * 1_000_000L / 44100L
val systemTimeUs = timestamp.nanoTime / 1000L
// 从这个锚点可以推算当前的播放位置
val nowUs = System.nanoTime() / 1000L
val currentPositionUs = playedUs + (nowUs - systemTimeUs)
}
timestamp.framePosition 来自内核的音频硬件计数器,精度在 1ms 以内,是真正的"已经从扬声器播出去的帧数",不是写入 ring buffer 的帧数——这个区别很重要,因为 AudioFlinger 的 ring buffer 本身就有 20~60ms 的延迟。
AudioFlinger 的延迟对同步的影响
一个常见误解是:AudioTrack.write() 写入数据后,声音立刻就出来了。
实际上:
AudioTrack.write(pcmData)
│ 写入 ring buffer(延迟 ≈ 0ms)
▼
AudioFlinger MixerThread
│ 混音多个应用的音频(20~40ms 周期)
▼
Audio HAL → DSP
│ DSP 处理延迟(5~15ms)
▼
扬声器/耳机输出
总延迟: 约 40~80ms
这就是为什么同步时要用 getTimestamp() 而不是用写入的字节数来计算时间——前者反映的是真实的"播出去"时刻,后者包含了 ring buffer 里还没有播出的部分。
卡顿检测与丢帧分析
识别音视频同步问题
# 实时查看 NuPlayer 丢帧情况
adb logcat | grep "dropping video"
# 输出示例:
# D NuPlayerRenderer: [video] late by 55.3ms, dropping frame
# 查看音视频时钟偏差
adb logcat | grep "AV"
# D NuPlayerRenderer: AV sync late by 12ms
# 查看整体渲染统计
adb shell dumpsys media.player | grep -A 20 "NuPlayer"
SurfaceFlinger 帧率分析
# 查看每个图层的帧统计
adb shell dumpsys SurfaceFlinger | grep -A 5 "Layer"
# 帧时间分布(jank 分析)
adb shell dumpsys gfxinfo <package_name>
# 输出包含:
# Total frames rendered: 1234
# Janky frames: 23 (1.86%) ← jank 率
# 90th percentile: 8ms
# 95th percentile: 12ms
# 99th percentile: 32ms ← P99 超过 16ms 说明有掉帧
Perfetto 精确分析
# 抓取包含音视频同步的完整 trace
adb shell perfetto \
-c - --txt \
-o /data/local/tmp/av_trace.pb << 'EOF'
buffers { size_kb: 102400 }
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "sched/sched_switch"
ftrace_events: "power/suspend_resume"
atrace_categories: "am"
atrace_categories: "audio"
atrace_categories: "video"
atrace_categories: "gfx"
atrace_categories: "input"
}
}
}
duration_ms: 10000
EOF
adb pull /data/local/tmp/av_trace.pb ./
# 在 https://ui.perfetto.dev 中打开,搜索 NuPlayer 相关 trace event
在 Perfetto 中能清楚看到:
- 每一帧的解码时间
- VSYNC 信号的到来时刻
- SurfaceFlinger 的合成耗时
- 音频写入和实际播出的时间差
实战:直播低延迟优化
直播场景是音视频同步最极端的考验:观众希望延迟尽可能低(< 3 秒甚至 < 1 秒),但又不能为了低延迟牺牲画质和流畅度。
默认 NuPlayer 的问题
NuPlayer 是为点播场景设计的,有较激进的缓冲策略(2~5 秒缓冲水位)。对于直播:
问题 1: 积累的缓冲 = 积累的延迟
问题 2: 缓冲不足时卡顿,而非平滑降级
问题 3: 音视频时钟偏差容忍窗口对直播来说太宽松
用 ExoPlayer 实现低延迟直播
ExoPlayer(Media3)提供了专门的低延迟直播支持:
// 低延迟直播播放器配置
val player = ExoPlayer.Builder(context)
.setLoadControl(
DefaultLoadControl.Builder()
// 直播场景:最小缓冲压缩到 500ms
.setBufferDurationsMs(
/* minBufferMs = */ 500,
/* maxBufferMs = */ 2000,
/* bufferForPlaybackMs = */ 500,
/* bufferForPlaybackAfterRebufferMs = */ 1000
)
.build()
)
.build()
// 低延迟直播媒体配置
val mediaItem = MediaItem.Builder()
.setUri(hlsLiveUrl)
.setLiveConfiguration(
MediaItem.LiveConfiguration.Builder()
// 目标延迟:1 秒
.setTargetOffsetMs(1000)
// 允许的延迟范围:0.5s ~ 2s
.setMinOffsetMs(500)
.setMaxOffsetMs(2000)
// 通过调整播放速率(0.97x ~ 1.03x)来维持目标延迟
.setMinPlaybackSpeed(0.97f)
.setMaxPlaybackSpeed(1.03f)
.build()
)
.build()
player.setMediaItem(mediaItem)
player.prepare()
player.play()
// 监听延迟变化
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
val currentLiveOffsetMs = player.currentLiveOffset
Log.d("LivePlayer", "当前延迟: ${currentLiveOffsetMs}ms")
}
}
})
ExoPlayer 的低延迟直播机制:当直播流延迟大于目标值时,将播放速率微调至 1.03x;当延迟小于目标值时,降速至 0.97x。这种微小的速率调整用户几乎感受不到(人耳对 3% 的速率变化不敏感),但能有效追赶或放缓直播流。
音视频同步的校准
自定义播放器时,如果发现音画不同步,可以通过以下方式校准:
// ExoPlayer:设置音视频时间偏移
player.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, format, mediaFormat ->
// presentationTimeUs: 这帧的 PTS
// releaseTimeNs: 系统决定渲染这帧的时刻(纳秒)
// 可以在这里插入自定义同步逻辑
}
// 手动设置音视频偏移(正值:视频滞后于音频)
player.setVideoFrameMetadataListener(...)
// MediaPlayer:AudioSync offset(通过 AudioTrack)
val audioTrack = ... // 获取当前 AudioTrack 引用
audioTrack.setLatency(customLatencyUs) // 非公开 API,不推荐
对于大多数应用,不需要手动校准——Android 默认的同步机制已经处理得足够好。手动干预通常是在做专业直播或视频制作工具时才需要。
高刷新率与可变刷新率
120Hz / 144Hz 下的同步挑战
高刷新率屏幕(120Hz / 144Hz)已经在旗舰手机上普及,VSYNC 周期缩短到 8.33ms / 6.94ms。这对视频播放带来了新的挑战:
30fps 视频在 120Hz 屏幕上:
视频帧周期: 33.33ms
VSYNC 周期: 8.33ms
比例: 4:1
→ 每帧视频需要在 4 个 VSYNC 中重复显示
→ 理想情况:每帧显示 4 个 VSYNC(33.33ms)
→ 问题:33.33ms / 8.33ms = 3.9996(不是整数!)
→ 长期下来,某些帧会交替显示 4 个或 3 个 VSYNC
→ 肉眼可见的轻微抖动(judder)
解决方案:自适应刷新率(JANK 消除)
Android 11 引入的 setFrameRate() API 允许应用声明视频的帧率,系统动态调整屏幕刷新率以避免抖动:
// 告诉系统这个 Surface 的内容帧率是 24fps
surface.setFrameRate(
24.0f, // 目标帧率
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, // 内容固定帧率
Surface.CHANGE_FRAME_RATE_ALWAYS // 总是切换
)
系统会把屏幕刷新率切换到 24fps 的整数倍(如 48Hz 或 96Hz),消除抖动。这也是为什么在部分手机上播放 24fps 电影时屏幕刷新率会自动变化。
VRR(可变刷新率)
Snapdragon 8 Gen 2+ 和部分联发科 SoC 支持 LTPO(低温多晶氧化物)可变刷新率技术,可在 1Hz ~ 120Hz 之间动态调整:
静止画面 → 1Hz(省电)
普通滚动 → 60Hz
游戏 / 视频 → 根据内容帧率切换
高帧率内容 → 120Hz / 144Hz
SurfaceFlinger 和 DisplayManager 共同管理这个动态刷新率,对于视频播放,setFrameRate() 声明会触发刷新率的自动切换。
总结:整个视频链路的全景回顾
至此,我们已经走完了 Android 视频子系统的完整旅程。
从 Camera2 API 捕获一帧图像,到 Camera HAL3 与 ISP 的硬件交互,再到 MediaCodec / Codec2 的硬件编解码,经过 NuPlayer 的 Source/Decoder/Renderer 三驾马车,最终通过 SurfaceFlinger + VSYNC 让像素点亮在屏幕上——每一个环节都是精密机器的一个齿轮。
音视频同步的核心原则只有一条:以音频时钟为绝对基准,视频帧主动向音频时钟对齐。
具体实现上的关键点:
- PTS 全程跟随:时间戳从容器解析开始,一路跟着数据包传递到渲染层,不在中途重算
- 音频时钟精度:
AudioTrack.getTimestamp()的framePosition来自内核硬件计数器,比轮询系统时钟准确得多 - 丢帧而非卡顿:落后超过 40ms 的视频帧直接丢弃,保证实时性
- 等待而非撕裂:超前超过 30ms 的视频帧延迟投递,等待音频追上来
- VSYNC 驱动渲染:所有图像更新都与 VSYNC 信号对齐,避免画面撕裂
- HWC 零拷贝:全屏视频走 Hardware Composer Overlay,不经过 GPU 合成
你可以做的事:
- 在自己的设备上运行
adb logcat | grep "dropping video",看看播放时有多少丢帧 - 用 Perfetto 分析一次视频播放的完整 trace,定位可能的卡顿点
- 如果开发直播应用,评估使用 ExoPlayer 的
LiveConfiguration来控制延迟目标 - 对于高帧率内容,调用
Surface.setFrameRate()让系统优化刷新率匹配