1) VSync 是什么、为什么影响播放流畅度
- VSync(Vertical Sync) 是显示器在每个刷新周期发出的"时钟脉冲"。Android 上,应用侧通过 Choreographer 、系统侧通过 SurfaceFlinger 用它来协调绘制与合成。
- 视频理想状态:帧释放时刻 = VSync 边界附近 。这样 GPU 合成能"赶上"这一帧,避免 撕裂/抖动(judder)/间歇性卡顿。
- 如果显示刷新率与媒体帧率不整除(如 24fps→60Hz),会产生周期性的时间对不齐(3:2 pulldown),需要对时策略 或切换显示刷新率来缓解。
2) ExoPlayer 的音画同步是怎么做的
2.1 谁当"主时钟"
-
有音频轨时 :ExoPlayer 以 AudioTrack 的播放时钟 为主(最稳定),视频对齐到音频时间轴。
-
无音频轨/静音解码 :使用内部 MediaClock(基于系统时钟),视频按自身 PTS 推进。
-
直播追赶:可微调播放速率(1.00x→1.01~1.05x)保持靠近 live edge。
2.2 视频帧"何时"送到 Surface
-
视频渲染线程拿到目标 展示时间戳 (PTS) → 计算距离"现在"还有多久 →
- 过早:sleep / busy-wait 到预计释放点;
- 过晚 :可能 丢帧 以追上音频;
-
释放(queue/release)时,ExoPlayer 用 VideoFrameRelease( Time/Helper ) 对齐显示的 VSync:
-
估计下一次 VSync 到来时间、加上设备经验偏移(vsync offset),把帧释放到"最接近的 VSync 前" ;
-
Android 11+(API 30+)还能调用 Surface.setFrameRate() 请求显示切换到更匹配的刷新率(例如 24/30/60/120Hz),降低 judder。
-
名称备注:在 ExoPlayer 2.x 里常见 VideoFrameReleaseTimeHelper;在 Media3 里对应为 VideoFrameReleaseHelper(内部封装,默认启用)。
2.3 音画不同步时会怎样
- 视频落后音频 :优先 丢弃一到多帧视频(onDroppedVideoFrames)追上;
- 视频领先音频:在渲染线程等待,或被 VSync 对齐延后;
- 音频欠喂(underrun) :触发 onAudioUnderrun,可能暂停/重缓冲,重新建立对齐;
- 切流/切分辨率:时间基切换时,Exo 会短暂重对齐,可能看到一次性的 dropped/late 计数上升。
3) 你能做的调优(从易到难)
3.1 让显示刷新率与视频帧率更匹配(强烈建议)
scss
val player = ExoPlayer.Builder(context)
.setVideoChangeFrameRateStrategy(
VIDEO_CHANGE_FRAME_RATE_STRATEGY_ONLY_IF_SEAMLESS
// 或 ALWAYS,取决于你是否允许非无感切换
)
.build()
// API 30+ 时,Exo 会对输出 Surface 调用 setFrameRate() 请求匹配的显示模式
-
目的:把 24/30/50fps 的内容尽量放到 24/48/60/120Hz 等"整数倍"刷新下,减少结构性抖动。
-
注意:是否真正切换取决于设备/系统策略与应用窗口设置。
3.2 选择更稳的渲染目标
-
优先 SurfaceView (全屏/大窗、追求稳帧/低延迟);需要圆角/动画再用 TextureView。
-
DRM 或高码率场景,SurfaceView + Secure path 更稳。
3.3 适度增加缓冲阈值,避免"音频欠喂"
ini
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
minBufferMs = 1500,
maxBufferMs = 5000,
bufferForPlaybackMs = 500,
bufferForPlaybackAfterRebufferMs = 1000
).build()
val player = ExoPlayer.Builder(context)
.setLoadControl(loadControl)
.build()
- 直播低延迟:配合 MediaItem.LiveConfiguration,目标 2--5s,允许 maxPlaybackSpeed≈1.02f 轻微追赶。
3.4 监控并针对性处理
kotlin
player.addAnalyticsListener(object : AnalyticsListener {
override fun onDroppedVideoFrames(e: EventTime, count: Int, elapsedMs: Long) { /* 记录/降清晰度 */ }
override fun onVideoSizeChanged(e: EventTime, v: VideoSize) { /* 比例/旋转适配 */ }
override fun onAudioUnderrun(e: EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedMs: Long) { /* 提升缓冲/降码率 */ }
})
- 掉帧尖峰:多数来自 vsync 未对齐 + 负载饱和或切流;必要时降低分辨率/码率,或放宽缓冲。
3.5 直播/长连通:允许轻微超速追赶(减少累计延迟)
scss
val liveCfg = MediaItem.LiveConfiguration.Builder()
.setTargetOffsetMs(3000)
.setMinOffsetMs(2000)
.setMaxOffsetMs(5000)
.setMaxPlaybackSpeed(1.02f) // 轻微加速追边缘
.build()
3.6 (可选)隧道模式(Tunneling)
- 部分 SoC 支持 音视频隧道 :硬件层完成对齐与合成,更低功耗/更精准 A/V 同步。
- 需同一路径的解码器与音频会话支持,适合全屏纯播放场景(UI 叠加受限)。
4) 常见现象与原因定位
现象 | 常见根因 | 对策 |
---|---|---|
周期性细微抖动(24→60Hz) | 帧率不匹配、pulldown | 启用 setVideoChangeFrameRateStrategy;或把显示刷新率手动调到 24/48/120Hz |
不规则卡顿/丢帧突增 | CPU/GPU 抢占、渲染线程被阻塞、切流重配置 | 降分辨率/码率,避免主线程阻塞;观察 onDroppedVideoFrames |
口型不同步 | 音频欠喂/蓝牙延迟/设备音频时钟漂移 | 关注 onAudioUnderrun;直播启用轻微超速;必要时对蓝牙做延迟补偿 |
低延迟直播越看越"远离直播边缘" | 网络波动 + 不追赶 | 设置 LiveConfiguration 允许微超速与偏移范围 |
5) 深入观察 VSync 对齐(可选)
想看每帧"将要释放"的时间戳与估计的 VSync:
java
val listener = VideoFrameMetadataListener { ptsUs, releaseNs, format, _ ->
// ptsUs: 帧展示时间戳(微秒,媒体时钟)
// releaseNs: 计划释放到 Surface 的时刻(纳秒,已考虑 vsync 对齐/offset)
// 这里可做日志/埋点用于诊断
}
player.videoComponent?.addVideoFrameMetadataListener(listener)
6) 实战建议(清单)
- 能切显示刷新率就切:API 30+ 开启 frame-rate matching,大幅减少结构性抖动。
- SurfaceView > TextureView(对流畅度/功耗友好;除非你要动画/圆角)。
- 音频为王:有音频时以音频时钟为基准,视频按需等待/丢帧。
- 直播要追边缘:用 LiveConfiguration + 轻微超速。
- 度量优先:把 first-frame-time、onDroppedVideoFrames、onAudioUnderrun、rebuffer 全部打点;先量后调。