6、视频渲染循环:自定义渲染循环、帧率控制(FPS)、使用Choreographer优化、VSync与渲染时机
视频渲染的核心,说白了就是一个字——“循环”。
你想想看,视频是一帧一帧的画面连续播放。每一帧从哪里来?怎么画上去?画完以后下一帧什么时候来?这一连串的问题,都需要一个渲染循环来回答。
我刚开始做视频播放器的时候,以为只要在子线程里不断调用onDraw就行了。结果呢?画面撕裂、CPU狂飙、掉帧严重……嗯,后来我才明白,渲染循环不是“死循环”,而是一门时机管理的艺术。
核心要点:渲染循环 = 帧生产 + 帧消费 + 时机同步。缺一个,你的视频就卡。
6.1 自定义渲染循环:从零开始搭一个循环
SurfaceView的渲染,不像普通View那样靠系统帮你调onDraw。你得自己开一个线程,自己控制什么时候画。
我习惯用这样的结构:
class RenderThread(private val surface: Surface) : Thread() {
private var isRunning = false
private var fps = 30f
private var frameIntervalMs = (1000f / fps).toLong()
override fun run() {
isRunning = true
while (isRunning) {
val startTime = System.nanoTime()
// 1. 检查Surface是否可用
if (!surface.isValid) continue
// 2. 锁定画布
val canvas = surface.lockCanvas(null) ?: continue
// 3. 绘制当前帧
drawFrame(canvas)
// 4. 提交画布
surface.unlockCanvasAndPost(canvas)
// 5. 帧率控制
val elapsed = (System.nanoTime() - startTime) / 1_000_000
val sleepTime = frameIntervalMs - elapsed
if (sleepTime > 0) {
Thread.sleep(sleepTime)
}
}
}
private fun drawFrame(canvas: Canvas) {
// 这里画你的视频帧
canvas.drawColor(Color.BLACK)
}
fun stopRendering() {
isRunning = false
}
}
这个循环看起来简单,但坑不少。我在项目中遇到过一个问题:lockCanvas在某些设备上会返回null,如果你不判空,直接崩。还有,unlockCanvasAndPost必须在lockCanvas之后调用,否则会抛异常。
注意:千万不要在渲染循环里做耗时操作!比如网络请求、文件读写、复杂计算。这些会直接拖垮帧率。我曾经在一个项目里把解码后的YUV转RGB放在渲染线程里做,结果30fps的视频直接掉到12fps……后来老老实实放到解码线程去处理。
6.2 帧率控制(FPS):不是越快越好
很多人以为帧率越高越好。其实不是。你想想看,视频源是30fps,你硬要渲染60fps,多出来的30帧全是重复帧,白白浪费CPU和电量。
帧率控制的核心就一句话:让渲染速度匹配视频源的速度。
| 视频源帧率 | 推荐渲染帧率 | 帧间隔(ms) |
|---|---|---|
| 24fps(电影) | 24fps | 41.67 |
| 30fps(常见视频) | 30fps | 33.33 |
| 60fps(高帧率) | 60fps | 16.67 |
| 可变帧率(VFR) | 动态调整 | 按需计算 |
我个人的做法是:用视频解码器输出的帧时间戳来控制渲染时机。比如解码器告诉你这一帧应该在第1000ms显示,下一帧在第1033ms显示,那你就按这个节奏来。不要自己瞎猜。
小技巧:如果视频源是可变帧率(VFR),建议用MediaCodec输出的presentationTimeUs作为时间基准。我踩过坑——用固定帧率去渲染VFR视频,结果画面忽快忽慢,用户直接投诉。
6.3 使用Choreographer优化:让系统告诉你什么时候画
前面那个Thread.sleep的方式,说白了是“盲等”。你不知道系统现在忙不忙,下一帧什么时候来。结果就是:要么等太久掉帧,要么等太短导致画面撕裂。
Android其实提供了一个好东西——Choreographer。它会告诉你:嘿,现在可以画下一帧了,VSync信号来了!
class ChoreographerRenderer(private val surface: Surface) {
private val choreographer = Choreographer.getInstance()
private var frameCallback: Choreographer.FrameCallback? = null
private var isRendering = false
fun start() {
isRendering = true
frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
if (!isRendering) return@FrameCallback
// frameTimeNanos 就是VSync的时间戳
renderFrame()
// 注册下一帧回调
choreographer.postFrameCallback(frameCallback!!)
}
choreographer.postFrameCallback(frameCallback!!)
}
private fun renderFrame() {
val canvas = surface.lockCanvas(null) ?: return
// 绘制...
surface.unlockCanvasAndPost(canvas)
}
fun stop() {
isRendering = false
choreographer.removeFrameCallback(frameCallback!!)
}
}
你看,用Choreographer的好处是:你不用自己算时间,系统帮你算。它会在每个VSync信号到来时回调你,你只管画就行。
我记得有一次优化一个直播项目,原来用Thread.sleep控制帧率,CPU占用率一直在30%以上。换成Choreographer后,直接降到8%。为什么?因为系统在空闲时不会回调你,你根本不用空转。
重要:Choreographer的回调是在主线程执行的。如果你在回调里做耗时操作,会卡住主线程,导致界面卡顿。所以,渲染工作一定要放到子线程。你可以用HandlerThread来接收回调,然后在子线程里渲染。
6.4 VSync与渲染时机:为什么你的画面会撕裂?
你有没有见过视频播放时,画面中间出现一条“断层”?上半部分是上一帧,下半部分是下一帧?这就是画面撕裂(tearing)。
为什么会这样?因为屏幕的刷新和你的渲染是异步的。屏幕正在从上往下刷新,你突然往缓冲区里写了一帧新的数据。结果屏幕读到一半,数据变了——撕裂就出现了。
VSync(垂直同步)就是来解决这个问题的。它告诉渲染引擎:等屏幕刷新完这一帧,你再往缓冲区里写数据。这样就能保证屏幕每次读取的都是完整的一帧。
Android的Choreographer就是基于VSync的。它会在每个VSync信号到来时回调你,让你准备下一帧。如果你能在下一个VSync之前准备好,那就完美;如果没准备好,那就掉一帧。
我的经验:在低端设备上,VSync信号可能不稳定。我遇到过一些千元机,VSync间隔忽长忽短,导致Choreographer回调也不规律。这时候,我建议加一个帧率统计模块,实时监控实际渲染帧率。如果发现掉帧严重,可以动态降低渲染质量(比如降低分辨率、跳过某些特效)。
6.5 综合实践:一个健壮的渲染循环
说了这么多,我们来整合一下。一个健壮的渲染循环应该包含:
- 线程管理:用
HandlerThread创建子线程,避免阻塞主线程 - 帧率控制:根据视频源帧率动态调整,不要硬编码
- Choreographer:用VSync信号驱动渲染,减少空转
- 异常处理:
lockCanvas失败、Surface销毁等场景要优雅处理 - 性能监控:统计实际帧率,发现掉帧及时调整
class VideoRenderLoop(private val surface: Surface) {
private val handlerThread = HandlerThread("RenderThread")
private lateinit var handler: Handler
private var isRunning = false
private var targetFps = 30f
fun start() {
handlerThread.start()
handler = Handler(handlerThread.looper)
isRunning = true
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!isRunning) return
handler.post {
renderFrame()
}
Choreographer.getInstance().postFrameCallback(this)
}
})
}
private fun renderFrame() {
if (!surface.isValid) return
val canvas = surface.lockCanvas(null) ?: return
try {
// 绘制视频帧
drawVideoFrame(canvas)
} finally {
surface.unlockCanvasAndPost(canvas)
}
}
private fun drawVideoFrame(canvas: Canvas) {
// 实际绘制逻辑
}
fun stop() {
isRunning = false
handlerThread.quitSafely()
}
}
这个结构我在多个项目里用过,稳定性和性能都不错。当然,具体细节还要根据你的业务场景调整。比如直播场景对延迟敏感,你可能需要降低缓冲区大小;本地播放则更注重流畅度。
最后提醒:千万不要在渲染循环里做new对象、分配内存的操作。GC(垃圾回收)会触发STW(Stop The World),导致渲染线程卡顿。我见过一个项目,每帧都new一个Paint对象,结果每过几秒就卡一下。后来改成复用对象,问题解决。
好了,这一章的内容就到这里。渲染循环是视频播放的基石,理解了它,后面的章节你会学得更轻松。
公众号:蓝海资料掘金营,微信deep3321