Android 渲染机制深度解析:Choreographer 与 VSYNC 如何驱动每一帧

Android 渲染机制深度解析:Choreographer 与 VSYNC 如何驱动每一帧

一句话收益 :理解 Choreographer 与 VSYNC 协作模型,找到掉帧的真正根源,写出真正流畅的 Android 应用。

适用版本 :Android 4.1 (API 16) ~ Android 15 (API 35)

阅读时长:约 20 分钟

1. 从一次滑动卡顿说起

你一定遇到过这种场景:RecyclerView 滑动时偶尔卡一下,systrace 显示某帧耗时 35ms,刚好超过 16.6ms 的门槛。你优化了 onBindViewHolder,减少了布局层级,但卡顿依然时隐时现。

问题的根源往往不在"做了什么",而在"什么时候做"。Android 渲染系统有严格的节拍------VSYNC 信号驱动的心跳。不跟上这个心跳,再快的代码也白费。

2. 核心组件关系总览

复制代码

Display Hardware

│ VSYNC 信号(60Hz/90Hz/120Hz)

▼

SurfaceFlinger ──────────────────────────────────────────────┐

│ SW_VSYNC / HW_VSYNC │

▼ │

DispSync (偏移补偿) │

│ │

├──► App VSYNC Offset (Choreographer) │

│ │ │

│ ▼ │

│ Choreographer │

│ ├── INPUT callbacks │

│ ├── ANIMATION callbacks │

│ ├── INSETS_ANIMATION callbacks │

│ ├── TRAVERSAL callbacks ◄── ViewRootImpl │

│ └── COMMIT callbacks │

│ │

└──► SF VSYNC Offset (SurfaceFlinger 合成) ◄──────────┘

关键类路径:

android.view.Choreographer --- 应用侧帧调度核心

com.android.server.display.DisplayManagerService --- 管理 HW_VSYNC

android.view.ViewRootImpl#scheduleTraversals() --- 触发重绘入口

android.graphics.SurfaceFlinger(Native)--- 合成并送显

3. VSYNC 信号的两级分发

3.1 硬件 VSYNC(HW_VSYNC)

显示器每完成一帧扫描,发出硬件中断。SurfaceFlinger 监听此中断,通过 DispSync 对信号建模,生成软件 VSYNC(SW_VSYNC),避免对每帧都占用真实硬件中断。

复制代码

HW_VSYNC ──► DispSync ──► SW_VSYNC (软件模拟)

│

├── App Phase Offset (约 0~2ms) ──► Choreographer

└── SF Phase Offset (约 6~8ms) ──► SurfaceFlinger

两个偏移量通过 surface_flinger.xml 配置,目的是让 App 渲染与 SF 合成错开执行,充分利用多核。

3.2 Choreographer 接收 VSYNC

Choreographer 不会持续监听 VSYNC,而是按需订阅:

kotlin

复制代码

// ViewRootImpl.java(简化)

void scheduleTraversals() {

if (!mTraversalScheduled) {

mTraversalScheduled = true

mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier() // 插入同步屏障

mChoreographer.postCallback(

Choreographer.CALLBACK_TRAVERSAL,

mTraversalRunnable, // 执行 measure/layout/draw

null

)

}

}

postCallback 最终调用 scheduleVsyncLocked() → DisplayEventReceiver.scheduleVsync(),向 SurfaceFlinger 请求下一个 VSYNC 通知,一次订阅,一次通知,不会持续唤醒。

4. 一帧的完整生命周期

复制代码

t=0ms ┌─ VSYNC 信号到达

│ Choreographer.doFrame() 被 Looper 唤醒

t=1ms ├─ INPUT 事件处理(触摸、按键)

│

t=2ms ├─ ANIMATION callbacks(ValueAnimator、ObjectAnimator tick)

│

t=4ms ├─ TRAVERSAL callbacks

│ ViewRootImpl.performTraversals()

│ ├── measure()

│ ├── layout()

│ └── draw() ──► Canvas 指令录入 DisplayList

│

t=10ms ├─ RenderThread 上传 GPU

│ HardwareRenderer 执行 DisplayList → GPU 绘制

│

t=14ms ├─ BufferQueue 生产者释放 Buffer

│

t=16.6ms└─ 下一个 VSYNC:SurfaceFlinger 消费 Buffer,送显

关键:整个链路必须在 16.6ms 内完成(60Hz)。超时则 SurfaceFlinger 无 Buffer 可用,重复上一帧 → 掉帧(Jank)。

5. 同步屏障(Sync Barrier)的作用

scheduleTraversals 中插入的 postSyncBarrier() 会阻塞所有普通 Message (isAsynchronous == false),只让异步 Message 通过。Choreographer 发出的回调都是异步 Message,因此 VSYNC 回调可以"插队",不被业务消息延迟。

复制代码

错误写法:

// 在主线程 Handler 大量 postDelayed 普通消息

handler.postDelayed({ heavyTask() }, 0)

// → 可能卡住同步屏障前的队列,让 TRAVERSAL 推迟到下一帧

正确写法:

// 耗时逻辑移到协程 IO 线程,仅在主线程更新 UI

lifecycleScope.launch(Dispatchers.IO) {

val result = heavyTask()

withContext(Dispatchers.Main) { textView.text = result }

}

6. 常见掉帧场景与根因分析

6.1 主线程耗时

现象 :systrace 中 Choreographer#doFrame 色块超宽,UI Thread 出现红色 Jank 标记。

原因 :主线程执行了 IO、序列化、数据库查询等耗时操作。

复现 :StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build())。

解决 :迁移到 Dispatchers.IO 或 Dispatchers.Default。

6.2 过度绘制

现象 :GPU 渲染模式分析 中蓝色基线过高,即便主线程无耗时逻辑也掉帧。

原因 :多层背景叠加、onDraw 在大区域反复 drawBitmap。

复现 :开发者选项 → 调试 GPU 过度绘制,红色区域即为问题点。

解决 :使用 clipRect() 限制绘制区域,移除不必要的背景。

kotlin

复制代码

// 错误写法:父 View 和子 View 都设置了 #FFFFFF 背景

// 问题:造成两次绘制同一区域

parent.setBackgroundColor(Color.WHITE)

child.setBackgroundColor(Color.WHITE)

// 正确写法:只保留最顶层必要背景

parent.setBackgroundColor(Color.WHITE)

// child 无需重复设置

6.3 布局层级过深

现象 :measure/layout 耗时占比高(> 5ms)。

原因 :LinearLayout 嵌套过深,RelativeLayout 双重测量。

复现 :Layout Inspector 中查看 View 树深度。

解决 :用 ConstraintLayout 打平层级,或使用 merge 标签减少冗余层。

6.4 RenderThread 阻塞

现象 :主线程 doFrame 正常,但 RenderThread 出现长尾。

原因 :Bitmap 在 RenderThread 第一次使用时上传 GPU(prepareToDraw 未提前调用);或 Canvas.drawBitmap 传入大图。

解决:

kotlin

复制代码

// 提前在后台线程预热 Bitmap GPU 上传

Glide.with(context)

.asBitmap()

.load(url)

.into(object : CustomTarget() {

override fun onResourceReady(resource: Bitmap, transition: Transition?) {

resource.prepareToDraw() // 触发 GPU 纹理上传

imageView.setImageBitmap(resource)

}

override fun onLoadCleared(placeholder: Drawable?) {}

})

7. Choreographer 高级用法

7.1 监听帧率与跳帧

kotlin

复制代码

class FrameMonitor {

private var lastFrameNanos = 0L

fun start() {

Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {

override fun doFrame(frameTimeNanos: Long) {

if (lastFrameNanos != 0L) {

val diffMs = (frameTimeNanos - lastFrameNanos) / 1_000_000

if (diffMs > 32) { // 超过两帧(16.6ms×2)认为掉帧

Log.w("FrameMonitor", "Jank detected: ${diffMs}ms")

}

}

lastFrameNanos = frameTimeNanos

// 继续监听

Choreographer.getInstance().postFrameCallback(this)

}

})

}

}

frameTimeNanos 是本帧 VSYNC 的预期 时间戳(纳秒),不是实际执行时间,用于动画插值比 System.nanoTime() 更准确。

7.2 动画中正确使用 frameTimeNanos

kotlin

复制代码

// 错误写法:用系统时钟做动画插值

val elapsed = System.currentTimeMillis() - startTime

// 问题:若本帧延迟执行,动画会出现跳跃

// 正确写法:用 Choreographer 回调的 frameTimeNanos

Choreographer.getInstance().postFrameCallback { frameTimeNanos ->

val elapsed = (frameTimeNanos - startTimeNanos) / 1_000_000f

val fraction = (elapsed / duration).coerceIn(0f, 1f)

view.translationX = interpolator.getInterpolation(fraction) * targetX

if (fraction < 1f) Choreographer.getInstance().postFrameCallback(this)

}

7.3 Android 12+ FrameMetrics API

kotlin

复制代码

// 监听每帧各阶段耗时细节

window.addOnFrameMetricsAvailableListener({ _, frameMetrics, _ ->

val totalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000

val layoutDuration = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) / 1_000_000

val drawDuration = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) / 1_000_000

val gpuDuration = frameMetrics.getMetric(FrameMetrics.GPU_DURATION) / 1_000_000

// 上报到 APM 平台

}, Handler(Looper.getMainLooper()))

8. 最佳实践

8.1 不要在主线程做任何 IO

做法 :使用 Dispatchers.IO + withContext 切换上下文。

原因 :VSYNC 回调在主线程执行,任何 IO 阻塞都会直接推迟下一帧渲染。

对比:不这样做 → 即使只有 5ms 的文件读取,也会让 16.6ms 的帧预算直接透支。

8.2 减少 measure/layout 中的动态计算

做法 :将不随滑动变化的尺寸缓存为成员变量,避免在 onMeasure/onLayout 内执行字符串拼接或对象创建。

原因 :RecyclerView 滑动时每个可见 item 每帧都可能触发 measure,GC 压力直接反映在帧耗时。

对比:不缓存 → 滑动时大量短生命周期对象触发 GC stop-the-world,产生间歇性 Jank。

8.3 使用 Hardware Layer 加速动画

做法 :对执行 alpha/translation/rotation 动画的 View,动画期间设置 LAYER_TYPE_HARDWARE,动画结束后恢复 LAYER_TYPE_NONE。

原因 :硬件层将 View 内容缓存为 GPU 纹理,动画期间仅变换纹理矩阵,无需重新 draw。

对比 :不用 → 每帧重新执行 onDraw,对复杂 View(如 RecyclerView item)开销极大。

kotlin

复制代码

view.animate()

.alpha(0f)

.withStartAction { view.setLayerType(View.LAYER_TYPE_HARDWARE, null) }

.withEndAction { view.setLayerType(View.LAYER_TYPE_NONE, null) }

.start()

8.4 Compose 中使用 graphicsLayer 替代属性动画

做法 :在 Compose 中对平移、旋转、缩放、alpha 动画优先使用 Modifier.graphicsLayer,配合 Animatable 或 animate*AsState。

原因 :graphicsLayer 的变换在 RenderThread 执行,不阻塞主线程 Composition。

对比 :直接修改 offset Modifier → 触发 Recomposition → 主线程参与 → 高频动画下丢帧风险大。

kotlin

复制代码

val scale by animateFloatAsState(if (pressed) 0.95f else 1f)

Box(

Modifier.graphicsLayer(scaleX = scale, scaleY = scale) // RenderThread 执行

) { /* content */ }

9. 用 Systrace/Perfetto 定位渲染问题

bash

复制代码

# 抓取 5 秒渲染相关 trace

python $ANDROID_HOME/platform-tools/systrace/systrace.py \

--time=5 gfx view res sched -o trace.html

# 或用 Perfetto(Android 10+,推荐)

adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/trace.perfetto-trace <

buffers { size_kb: 63488 fill_policy: DISCARD }

data_sources { config { name: "android.surfaceflinger.frametimeline" } }

data_sources { config { name: "track_event" } }

duration_ms: 5000

EOF

adb pull /data/misc/perfetto-traces/trace.perfetto-trace

在 Perfetto UI(ui.perfetto.dev)中关注:

Frame Timeline 轨道:绿色=正常,红色=Jank,黄色=预测偏差

Main Thread 与 RenderThread 的色块宽度及重叠情况

GPU Completion 时间线:GPU 处理时间是否压到下一 VSYNC

10. 总结

VSYNC 是节拍,Choreographer 是指挥:所有 UI 更新必须在 VSYNC 回调内完成,超时即掉帧。

同步屏障保障帧回调优先级 :scheduleTraversals 插入屏障让 TRAVERSAL 不被业务消息阻塞。

主线程只做 UI,IO 全部异步:任何耗时操作都是对 16.6ms 帧预算的直接侵占。

RenderThread 是第二道关卡:Bitmap 上传、大图绘制同样会造成 Jank,需提前预热。

frameTimeNanos 是动画的真实时钟:用它做插值比系统时钟更平滑,避免动画跳跃。

核心结论:掉帧本质上是对 VSYNC 节拍的"失约",优化的终点是让主线程和 RenderThread 在每个 16.6ms 窗口内都能准时完成自己的工作。

参考资料

Choreographer 官方文档

Android 渲染机制 --- Jank 检测

Perfetto 文档

AOSP 源码:

frameworks/base/core/java/android/view/Choreographer.java

frameworks/base/core/java/android/view/ViewRootImpl.java

frameworks/native/services/surfaceflinger/Scheduler/DispSync.cpp

frameworks/native/libs/gui/DisplayEventReceiver.cpp