本文以传统 View 系统和 Android 11 为背景,沿默认的硬件加速路径,完整追一帧画面从产生到上屏的过程:从 VSync 出发,经过主线程、RenderThread、GPU、BufferQueue 和 SurfaceFlinger,直到显示在屏幕上。前五章按管线顺序推进,第六章集中处理几个绕不过去的概念问题。
下面这些名字会反复出现,读到拿不准的地方可以回来对一下:
| 环节 | 名字 |
|---|---|
| 帧调度 | VSync 屏幕刷新信号 · Choreographer 帧回调的编排者 · doFrame() 一次帧机会的入口 |
| 绘制记录 | DisplayList 录下来的绘制指令,不是像素 · RenderNode 承载变换属性的节点 |
| 分层角色 | Canvas 绘制接口 · HWUI 硬件加速框架 · Skia 2D 图形引擎 · RenderThread HWUI 的工作线程 |
| 像素资源 | Bitmap CPU 侧输入像素 · 纹理 GPU 可采样的形式 · GraphicBuffer 一帧的输出缓冲 |
| 上屏 | BufferQueue Buffer 流转队列 · Fence 异步完成信号 · SurfaceFlinger 图层合成者 · HWC 硬件合成器 |
一、一帧的时间预算从哪来
屏幕不是连续发光的,它每秒重画固定次数。这个次数就是刷新率,60Hz 表示每秒 60 次,两次刷新之间相隔 1000ms / 60 ≈ 16.67ms。
这 16.67ms 就是一帧的时间预算。应用要让 60Hz 屏幕的每次刷新都拿到新内容,就得在两次刷新之间把新画面准备好。超了,屏幕这一轮只能把旧画面再显示一遍。
为什么是 60Hz
60 这个数字不是 Android 定的,是从更早的显示体系继承来的。
源头在 CRT 时代。早期显示器的刷新率跟市电频率挂钩------北美市电 60Hz,欧洲 50Hz------按市电频率刷新可以避免电源纹波和画面扫描之间形成缓慢移动的可见条纹。NTSC 制定视频标准时沿用了 60,PAL 用了 50。
等到液晶面板普及、刷新率不再受市电约束,整条产业链的信号规格、时序控制和视频素材都已经围绕 60Hz 建好了,改动的代价远大于收益。
另一个原因是 60Hz 恰好够用:人眼在这个频率下基本感知不到闪烁,运动也足够连贯。往上提升仍有收益,但边际效应明显下降,成本和功耗却是实打实的。
早期智能手机直接复用了成熟的面板和显示控制器,60Hz 就这样成了移动端的事实起点。Android 自己从来没把 16.67ms 写进规范,它读取设备当前的显示模式,按对应周期调度 VSync------只是在很长一段时间里,这个值一直是 16.67ms。
本文后续都以 60Hz 为背景。高刷新率设备的帧预算另有一层复杂度,放在文末扩展阅读。
卡顿是什么
"每帧做完低于 16.67ms 就不卡"是个够用的近似,但它漏掉了几种情况:帧任务可能启动得太晚,自身执行再快也赶不上这一轮刷新;GPU、BufferQueue 或 SurfaceFlinger 也可能在后半段拖住。更贴近现象的说法是:
卡顿是某一帧没能在预期的显示截止时间前呈现,屏幕继续显示旧画面,或者帧与帧的间隔变得不均匀。
注意后半句。间隔不均匀本身就是卡顿,即使每帧的耗时都不算长。稳定的 50fps 观感往往好过在 60fps 和 40fps 之间来回摆动。
二、主线程如何接入帧调度
Android 主线程通过 Looper.loop() 串行处理消息。Activity 生命周期、Handler 任务、输入事件和帧调度都可能进入同一个消息队列:
text
主线程 Looper.loop()
│
├─ Activity 生命周期回调
├─ Handler / Runnable
├─ 输入事件
├─ VSync 对应的帧回调
│ └─ Choreographer#doFrame()
├─ 其他消息
└─ 没有消息时休眠等待
doFrame() 是被动触发的:动画、Traversal 等工作请求下一帧时,Choreographer 才去申请 VSync,VSync 到达后通过主线程 Looper 执行一次 doFrame()。没人请求就不申请,主线程该休眠就休眠。
既然帧回调也是一条消息,它就得和其他消息抢主线程。框架为此留了后门:scheduleTraversals() 会往消息队列插一道同步屏障,让 Choreographer 的帧消息以异步消息的身份越过前面排队的普通消息。
但这道屏障只能让帧消息插队,不能打断已经在跑的消息。主线程正在执行一个 50ms 的任务,帧消息排在它后面,屏障也帮不上------Looper 没有抢占。这段等待会记在 VSync Delay 或 Misc 里,也是主线程做重活会直接掉帧的根本原因。
在 Android 11 中,doFrame() 按顺序执行五类回调:
text
Choreographer.doFrame()
├─ doCallbacks(CALLBACK_INPUT)
├─ doCallbacks(CALLBACK_ANIMATION)
├─ doCallbacks(CALLBACK_INSETS_ANIMATION)
├─ doCallbacks(CALLBACK_TRAVERSAL)
└─ doCallbacks(CALLBACK_COMMIT)
这五类回调和后文呈现模式分析的八个统计阶段名字有重叠,但不是一一对应的两套概念,读柱状图时留意这一点。
1. INPUT:消费本帧的批量输入
触摸屏的采样率往往高于屏幕刷新率,60Hz 的屏幕配 120Hz 甚至更高的触摸采样很常见。手指滑动时,两个 VSync 之间会攒下好几个位置采样点。
这就有个选择:每来一个采样点就画一遍,还是攒起来一次性处理?前者一帧之内要重复绘制好几次,纯属浪费;后者只用最后一个点,中间的轨迹信息就丢了。
Android 的做法是攒起来但不丢弃。可以合并的连续 MotionEvent(主要是滑动中的 MOVE)先在输入系统排队,应用在本帧开始时一次取出截至 frameTimeNanos 的这一批,交给同一轮事件分发。这一帧既用上了尽可能新的手指位置,又只走一遍完整绘制。CALLBACK_INPUT 就是取出这一批的时机。
Android 11 的关键调用关系简化后是:
text
WindowInputEventReceiver.onBatchedInputEventPending()
→ ViewRootImpl.scheduleConsumeBatchedInput()
→ Choreographer.postCallback(CALLBACK_INPUT, ...)
→ ConsumeBatchedInputRunnable.run()
→ doConsumeBatchedInput(frameTimeNanos)
→ consumeBatchedInputEvents(frameTimeNanos)
→ WindowInputEventReceiver.onInputEvent()
→ enqueueInputEvent()
→ doProcessInputEvents()
→ ViewRootImpl 的 InputStage 链
→ View / ViewGroup 事件分发
按键事件、不可合并的事件、请求立即消费的事件走各自的路径,不排在这里。所以 CALLBACK_INPUT 是可批处理输入的对齐时机,不是应用全部事件分发的入口。
onTouchEvent() 里塞复杂逻辑,占的是主线程时间,直接压缩同一帧后面动画和 Traversal 的余量。这就是滑动时越卡、手感越滞后的来源之一:输入处理本身把留给绘制的时间吃掉了。
2. ANIMATION:计算本帧动画状态
ValueAnimator、ObjectAnimator、ViewPropertyAnimator 通过 AnimationHandler 接收帧回调,用同一个帧时间计算本帧属性值:
text
CALLBACK_ANIMATION
→ AnimationHandler 的 FrameCallback
→ AnimationHandler.doAnimationFrame()
→ ValueAnimator.doAnimationFrame()
→ 计算 elapsed fraction
→ Interpolator / TypeEvaluator 计算属性值
→ 更新目标属性
→ AnimatorUpdateListener.onAnimationUpdate()
属性更新之后发生什么,取决于改的是哪类属性,代价差别很大:
translationX、scaleX、alpha:只更新 RenderNode 属性,不重新 Measure/Layout,也不重录 DisplayList。这是最便宜的一档。- 宽高、约束等布局属性:触发
requestLayout(),本帧后面要走 Measure/Layout。 - 自定义绘制相关的属性:触发
invalidate(),对应 View 的绘制记录失效,Draw 阶段要重录。
补间动画、Drawable 动画、Compose 动画、物理动画各有自己的封装,不都走 AnimationHandler → ValueAnimator 这条链。但只要动画跟着界面刷新,它总要挂在某个由 VSync 驱动或对齐的帧时钟上。
Animation 阶段变长,通常是每帧的属性计算、onAnimationUpdate() 或属性更新引发的连带工作。onAnimationStart() 只跑一次,很少是主因。
3. INSETS_ANIMATION:汇总系统栏和输入法动画
Android 11 新增了独立的 CALLBACK_INSETS_ANIMATION,处理输入法、状态栏、导航栏、手势导航区域这类 Insets 动画。
它排在普通动画之后、Traversal 之前,位置是有讲究的:先把同一帧内的 Insets 变化汇总完,再让最新的 Insets 参与本帧布局和绘制。反过来的话,布局用的就是上一帧的窗口内边距。
4. TRAVERSAL:Measure、Layout 与 Draw
View 树需要更新时,ViewRootImpl 通过 scheduleTraversals() 注册 CALLBACK_TRAVERSAL:
text
CALLBACK_TRAVERSAL
→ ViewRootImpl.mTraversalRunnable.run()
→ doTraversal()
→ performTraversals()
├─ performMeasure()
├─ performLayout()
└─ performDraw()
requestLayout() 请求重新测量布局,invalidate() 标记绘制内容失效。两者都只是打标记加调度,真正的 Measure/Layout/Draw 在随后的 Traversal 里统一执行。这也是为什么连续调用十次 invalidate() 不会画十遍。
硬件加速下,Draw 阶段在主线程上做的是录制而不是绘制------把失效 View 的绘制操作记成 DisplayList,同步 RenderNode 状态,然后交给 RenderThread:
text
performDraw()
→ ThreadedRenderer.draw()
→ 更新 View 树中的 DisplayList / RenderNode
→ syncAndDrawFrame()
→ RenderThread 继续处理
5. COMMIT:主线程帧回调的最后阶段
CALLBACK_COMMIT 是 doFrame() 的最后一类回调,位置在 Traversal 之后,适合放那些必须等本轮 Traversal 结束才能做的收尾工作。
它是主线程回调链的终点,不是 GPU 那一侧的起点。执行到 COMMIT 时,RenderThread 和 GPU 很可能还在并行处理这一帧。
COMMIT 为什么会修正动画时间
设想一个动画刚启动:第一帧的属性值已经算出来了,但紧接着的首次 Traversal 特别慢,跨过了三四个刷新周期。如果动画起点保持不变,用户刚看到第一张画面,下一帧的进度就已经跳到了三四帧之后------开头一顿,然后猛地一跳。
Choreographer 在执行 COMMIT 回调前会检查 Traversal 期间是否跨过了多个刷新周期,必要时修正这一阶段报告的 frame time。ValueAnimator 跑第一帧时向 AnimationHandler 注册一个一次性 COMMIT 回调,回调里 commitAnimationFrame() 把动画起点往后挪:
text
第一帧 ANIMATION:计算出初始属性
↓
TRAVERSAL:首次布局或绘制耗时,跨过多个刷新周期
↓
COMMIT:拿到更接近实际生效时刻的帧时间
↓
ValueAnimator:向后调整 mStartTime
↓
下一帧:从接近正常的一帧进度继续,而不是大幅跳跃
调整的是后续帧计算用的起点,已经写进 View 的当前帧属性不会回头重算。这套机制补偿的是动画启动到第一帧画出之间的延迟,只在开头生效,不会因为中途某帧 Traversal 慢就把整段动画一直往后顺延。
想知道"这一帧画完了"该用哪个回调
COMMIT 听起来像是提交完成的信号,于是很自然会想:要在一帧真正画完之后做点事(比如统计首帧耗时、启动后续动画),是不是就该用它?
不该。COMMIT 只保证主线程这一轮回调跑完了,此刻 RenderThread 可能刚开始 sync,GPU 一条命令都还没执行。Android 里另有一个名字很像的回调专门管这件事:
Choreographer.CALLBACK_COMMIT:主线程doFrame()的最后一个阶段,跟渲染进度无关。ViewTreeObserver.registerFrameCommitCallback():硬件渲染这一帧的内容已生成并提交到 swap chain 之后触发,只对硬件渲染有效。
即便是后者,触发时画面通常也还没真正显示在屏幕上------Buffer 还在 BufferQueue 里等 SurfaceFlinger 来取。想知道"用户什么时候看到了这一帧",在 Android 11 上只能从 trace 里推,这是 Android 12 引入 FrameTimeline 要解决的问题之一。
三、为什么回调按这个顺序执行
顺序是被数据依赖逼出来的,前一阶段的产出正是后一阶段的输入。以手指松开后的 fling 滚动为例:
text
INPUT:消费手指滑动,可能启动 fling
↓
ANIMATION:计算本帧滚动位置
↓
调用 requestLayout() 或 invalidate()
↓
TRAVERSAL:执行本帧 Measure / Layout / Draw
↓
COMMIT:执行依赖本轮 Traversal 已完成的收尾回调
doCallbacks() 在每个阶段开始时才去取该类型当前到期的任务,而不是在 doFrame() 一开头就把五类回调全部快照下来。这个细节决定了什么能挤进同一帧:
- INPUT 阶段启动的动画,Animation 回调赶得上同一帧。
- Animation 阶段调用的
requestLayout(),Traversal 赶得上同一帧。 - Traversal 结束之后才请求布局,只能等下一帧------这也是在
onDraw()里调requestLayout()会拖慢一切的原因。
四、沿着硬件加速主线走完一帧
text
主线程:
VSync → doFrame
→ INPUT
→ ANIMATION
→ INSETS_ANIMATION
→ TRAVERSAL
→ Measure / Layout
→ 录制或更新 DisplayList
→ syncAndDrawFrame ─────────────┐
→ COMMIT │
▼
RenderThread: Sync RenderNode
→ 准备绘制资源(Sync & Upload)
→ 提交绘制命令(Issue Commands) ─→ GPU
→ eglSwapBuffers │
│ │
│ Buffer + Fence │ 写入
▼ │
BufferQueue: QUEUED │
│ │
│ 使用前等待 Fence ◀──────────┘ 完成后发信号
↓
SurfaceFlinger: 获取 Buffer
→ 组织图层合成
↓
HWC / GPU: 完成合成
↓
屏幕: 显示
这张图画的是职责和依赖,不是严格的串行时序。GPU 异步执行绘制命令,Buffer 甚至可以带着一个尚未完成的 Fence 就进入 BufferQueue,等 SurfaceFlinger 真要用它的时候再去等 Fence。先入队、后同步,流水线就不必在每个交接点停下来对齐。
图里的 BufferQueue 也值得单独说一句位置。它是连接生产者和消费者的数据结构,跑在系统内存里,跟 GPU 是两回事,管的是 Buffer 槽位在 FREE → DEQUEUED → QUEUED → ACQUIRED → FREE 之间的流转。应用窗口、状态栏、导航栏这些 Surface 各有自己的 BufferQueue,SurfaceFlinger 从多个可见 Surface 各取一个 Buffer,再组织合成。
五、GPU 呈现模式分析的八个阶段
开发者选项里打开"HWUI 呈现模式分析"(旧版本叫"GPU 呈现模式分析"),屏幕上会出现彩色柱状图。Android 6.0 之后是八个阶段,各版本的名称和配色略有差异:
text
Misc / VSync Delay
→ Input Handling
→ Animation
→ Measure / Layout
→ Draw
→ Sync & Upload
→ Issue Commands
→ Swap Buffers

每根竖柱是一帧,从下往上按阶段堆叠,某段越高就是这个阶段这一帧花的时间越长。横穿柱状图的绿线画在 16.67ms,是 60fps 的参考预算。这条线的位置是写死的,高刷设备上它不会跟着刷新率下移。

下表把图例中合成一色的 Misc Time 和 VSync Delay 拆开列------它们在图上共用一个色段,但成因和排查方向完全不同。
| 阶段 | 这一段在计时什么 | 常见变长原因 |
|---|---|---|
| Misc Time | 没被归入其他阶段的主线程工作,是余下的零碎而非总计。 | Binder 回调、非帧相关的 Handler 消息、其他未分类工作。 |
| VSync Delay | VSync 已到,但 doFrame() 还没能开始的那段等待。 |
主线程前序消息过重压着,或者线程没及时拿到 CPU。前者查主线程消息队列,后者查调度,两条不同的路。 |
| Input Handling | 应用处理本帧输入回调的时间。 | dispatchTouchEvent()、onTouchEvent()、滚动处理逻辑过重。 |
| Animation | 计算本帧所有活动 Animator 的属性值,以及执行相关更新回调。 | Animator 数量多、Evaluator 复杂、onAnimationUpdate() 里有重逻辑。 |
| Measure / Layout | 遍历 View 树做测量、定尺寸、排位置。 | View 多、嵌套深、重复测量、频繁 requestLayout()、自定义 onMeasure() / onLayout() 过重。 |
| Draw | 把失效 View 的绘制操作录成或更新为 DisplayList。 | 大量 View 同时失效、onDraw() 逻辑复杂、绘制过程里分配对象。 |
| Sync & Upload | 同步渲染状态,把本帧新出现或有变化的 Bitmap 等资源准备成 GPU 能采样的纹理。 | 首次显示大图、大量缩略图、Bitmap 频繁变化、纹理缓存被淘汰后重新上传。 |
| Issue Commands | RenderThread 遍历本帧要画的 DisplayList,把绘制命令提交给图形驱动。 | 绘制命令多、裁剪变换复杂、过度绘制、单个 DisplayList 本身很重。 |
| Swap Buffers | RenderThread 提交本帧结束信号、交换 Buffer 时卡在那里等的时间,不覆盖 Buffer 送到屏幕的后续路程。 | GPU 命令队列满了、GPU 还在处理前几帧、没有空闲 Buffer、Fence 或下游消费没推进。 |
六、我遇到了这些问题
八个阶段的名字看懂了,管线也走通了,但真正让人卡住的往往是概念之间的关系:这些名字分别站在哪一层,谁能复用谁,等待到底发生在哪。这一节把三个问题串起来回答。
Canvas、Skia、HWUI、RenderThread、GPU 各自是什么
这五个名字讨论绘制时经常混着出现,其实分属不同层次,自上而下排开是这样:
text
Canvas ------ API 层:应用调用的绘制接口
↓
HWUI ------ 框架层:Android 的硬件加速渲染框架
├ DisplayList / RenderNode ------ 记录下来的绘制指令
└ RenderThread ------ 执行这些指令的线程
↓
Skia ------ 引擎层:把绘制指令翻译成图形 API 调用
↓
GPU 驱动 / GPU ------ 硬件层:真正生成像素
Canvas 只是接口。在 onDraw(Canvas canvas) 里调 drawCircle(),硬件加速下不会立刻画出一个圆,只是往当前 View 的 DisplayList 里追加一条记录。
HWUI 是整套硬件加速渲染框架的名字,DisplayList、RenderNode、RenderThread 都归它。其中 RenderThread 是真正干活的那条线程,每个使用硬件加速的进程一条,跟主线程并行。
Skia 在更下面,是个 2D 图形引擎,负责把抽象的绘制指令翻译成具体的图形 API 调用。Android 现在的 HWUI 默认走 Skia 的 GL 或 Vulkan 后端,所以硬件加速下 Skia 也在链条里,只是它输出 GPU 命令而不是像素。软件渲染同样用 Skia,换的是后端------CPU 栅格化。
最下面是 GPU,接收驱动转换后的命令,把像素写进 GraphicBuffer。
另外三个容易混淆的名字也能挂到这条链上,它们分别在输入端、中段和输出端:
- Bitmap:CPU 侧的输入像素,比如刚解码出来的图片。
- 纹理:Bitmap 经过 Sync & Upload 之后,GPU 能采样的形式。
- GraphicBuffer:GPU 绘制的输出,整个 Surface 当前帧的结果缓冲区。
Bitmap 没变、纹理还在缓存里,后续帧直接复用,不用重新上传。这里的上传指让资源变成 GPU 可用的形式,在统一内存架构的设备上可能只是一次地址映射,并不发生真正的显存拷贝。
DisplayList 为什么能复用,什么时候重建
DisplayList 记的是绘制指令,不是像素。"在 (100, 200) 画一个半径 50 的红圆"这条记录,跟这个圆最终落在屏幕哪个位置无关------位置由 RenderNode 上的变换矩阵决定。指令和变换分离,这就是复用能成立的基础。
所以一个 View 平移时,DisplayList 一个字节都不用改,只更新 RenderNode 的 translationX,RenderThread 拿同一批指令配新矩阵重画一遍。同理,alpha 和 scale 也不触发重录。
重建的触发条件是 View 被标记为失效,且这一帧需要它参与绘制。落到具体调用上:
invalidate()把 View 标脏,下一次 Draw 阶段重新执行onDraw(),重录 DisplayList。- Measure/Layout 改了尺寸,绘制内容依赖尺寸,也要重录。
- 只改 RenderNode 属性(平移、缩放、透明度、旋转)不重录。
用 translationY 做位移动画因此比每帧改 layout 参数快得多:前者只碰一个属性,后者要走完 Measure、Layout、Draw 三步,还得重录 DisplayList。
Draw 和 Issue Commands 经常不同步,根子也在这里。两个阶段一个在录,一个在放:Draw 把绘制操作录成 DisplayList,Issue Commands 把需要执行的 DisplayList 翻译成命令交给驱动。平移动画里 Draw 几乎为零,Issue Commands 该花的时间一分不少------圆终究要在新位置画出来。
所以 Issue Commands 的耗时跟 View 数量不成正比。一个复杂的自定义 View 可能只有一个主 DisplayList,里面装着几百条绘制命令;一屏几十个简单 View 反而能靠缓存和裁剪省掉大部分工作。看到 Issue Commands 高就去砍 View 层级,方向未必对。
Swap Buffers、BufferQueue、SurfaceFlinger 之间为什么会产生等待
先说清 BufferQueue 里 Buffer 的流转。一块 Buffer 在生产者和消费者之间有四个状态:
text
FREE ──dequeue──→ DEQUEUED ──queue──→ QUEUED ──acquire──→ ACQUIRED
↑ (应用正在画) (等待合成) (SF 正在用)
└──────────────────── release ←──────────────────────────────┘
Buffer 的数量有限,通常三块,也就是三重缓冲。RenderThread 要画新一帧,得先 dequeueBuffer() 拿一块 FREE 的。要是三块都占着------一块应用自己在画,一块在队列里等合成,一块 SurfaceFlinger 正在用------dequeueBuffer() 就地阻塞,直到有人释放。
这是 Swap Buffers 变长的第一种成因,方向是下游倒逼上游:SurfaceFlinger 忙、合成慢、Buffer 迟迟不释放,压力一路顶回应用的 RenderThread。
第二种成因在 GPU 侧。RenderThread 通过驱动异步给 GPU 投喂命令,投得比 GPU 消化得快时,命令队列会满,eglSwapBuffers() 只能等。
两种成因合起来,"CPU 等待 GPU"的准确含义就清楚了:RenderThread 这一条 CPU 线程暂时提交不下去,不是整个 CPU 停摆,也不是主线程在等。
排查上更要紧的一点是,Swap Buffers 不是当前帧 GPU 耗时的计时器。因果关系常常错位一两帧------这一帧卡在 swap,可能是前面几帧的命令还堆在 GPU 队列里,也可能是 SurfaceFlinger 上一轮合成慢了。看到这一段高,第一步不是优化本帧的 onDraw(),而是往下游找。
可以看的线索有三条:dequeueBuffer 的实际阻塞时长、GPU 轨道是否真的繁忙、SurfaceFlinger 的合成耗时。GPU 空闲但 Swap Buffers 高,问题几乎肯定在 BufferQueue 或下游。
七、拿这八个阶段做第一轮分类
柱状图的用处是快速缩小范围,它给的是方向,不是根因:
- Input、Animation、Measure/Layout、Draw 高:问题在主线程对应的回调里。
- Sync & Upload 高:查新出现或频繁变化的 Bitmap 和纹理。
- Issue Commands 高:查绘制命令数量、复杂裁剪、过度绘制、重效果。
- Swap Buffers 高:往下游找,按上一节的顺序区分 GPU、BufferQueue、SurfaceFlinger。
- Misc / VSync Delay 高:查
doFrame()之前主线程在干什么,以及线程调度是否及时。
再往下要靠 Perfetto 或 Systrace,看主线程、RenderThread、线程调度状态、dequeueBuffer / queueBuffer、GPU 和 SurfaceFlinger 这些轨道。Android 11 上没有 Android 12 之后的完整 FrameTimeline,一帧的端到端归属得自己从这几条轨道拼出来:改一处输入,看对应轨道怎么变,再下结论。单看一个指标不足以定案。
最后一条经验:柱状图上最高的那一段,未必是最该动手的那一段。
八、扩展阅读
1. 高刷新率设备上的帧预算
90Hz、120Hz 面板普及之后,一帧 16.67ms 就不再是通用答案了。刷新周期照旧用 1000ms / 刷新率 算:
| 刷新率 | 单个刷新周期 |
|---|---|
| 60Hz | 约 16.67ms |
| 90Hz | 约 11.11ms |
| 120Hz | 约 8.33ms |
那么 120Hz 是不是把性能要求翻了一倍?只有当应用真想让每次刷新都拿到新画面时才是。8.33ms 里要走完 doFrame 全套加上 RenderThread 提交,这个要求确实比 60Hz 苛刻得多。
但应用并不必这么做。120Hz 屏幕上稳定跑 60fps 是完全正常的选择:每两次刷新提交一帧,同一张画面显示两个刷新周期,观感和 60Hz 屏幕上的 60fps 一致。这时候帧预算仍然是 16.67ms。Android 15 之后系统对游戏默认按 60Hz 处理,要更高帧率得显式申请,理由就是功耗------高刷不是免费的。
卡顿的判定标准因此也不能换成 8.33ms。要看的是应用有没有守住自己定的呈现节奏、帧间隔是否均匀,依据是系统为这一帧选择的目标呈现时间和截止时间,而非绝对耗时跟一个刷新周期比大小。
高刷还有个容易忽略的副作用:补救的粒度变细了。目标 60fps 的应用在 60Hz 屏上错过一次提交,下一个机会在 16.67ms 之后,这一帧铁定停留 33.3ms;同样的应用在 120Hz 屏上,下一个 VSync 只隔 8.33ms,赶上了就是停留 25ms。同样掉一帧,观感更轻。反过来,目标 120fps 的应用单次掉帧只损失 8.33ms,但预算本来就紧,掉帧频率通常更高。
跟这套机制打交道靠两个 API:Display.getSupportedModes() 拿到设备支持哪些模式,Surface.setFrameRate() 告诉系统应用希望的帧率,系统据此挑选显示模式。视频播放场景收益最直接------24fps 的片源在 120Hz 下每帧显示 5 个周期,节奏均匀;在 60Hz 下只能 3、2、3、2 交替,也就是 3:2 pulldown 带来的抖动。
2. 软件渲染路径,以及硬件加速到底快在哪
正文走的是默认的硬件加速路径。软件渲染在今天已经是少数场景------显式关闭硬件加速、某些 SurfaceView 用法、部分不支持硬件加速的绘制操作------但把它拿来对照,硬件加速的收益会清楚很多。
两条路径共用 VSync、doFrame()、Measure/Layout、BufferQueue 和 SurfaceFlinger,分岔只在 Draw 之后:
text
硬件渲染 软件渲染
主线程 Draw 主线程 Draw
└ 录制 DisplayList └ lockCanvas
↓ syncAndDrawFrame → View.draw()
RenderThread → Skia CPU 栅格化
└ Sync & Upload → unlockCanvasAndPost
└ Issue Commands ─→ GPU (主线程一路做完)
└ eglSwapBuffers ↓
↓ BufferQueue
BufferQueue
最大的差别是流水线深度。硬件路径有三级------主线程录制、RenderThread 提交、GPU 栅格化,三者可以同时处理不同帧:主线程在录第 N+1 帧的时候,RenderThread 在提交第 N 帧,GPU 还在画第 N-1 帧。软件路径只有一级,主线程从录制一路做到栅格化,全程串行。
于是有个反直觉的结果:硬件路径的单帧总耗时可能反而更长,多出来的是跨线程同步的开销;但三级并行让每个刷新周期都能产出一帧,而软件路径的一帧必须在主线程一口气做完。顺带地,主线程也彻底卸下了栅格化,只剩记录指令这件轻活。
另一处差别在复用的层次,这一点最容易混淆。软件渲染也有复用机制,Dirty Region 让它只重绘受影响的区域。但 Dirty Region 复用的是未变区域已经算好的像素,DisplayList 复用的是未变 View 的绘制指令。
指令层的复用更靠前,余地也更大:同一批指令换个变换矩阵就能画到新位置,像素层做不到------像素一旦算出来就绑死在那个位置上,位置变了只能重算。平移动画在硬件加速下几乎免费、在软件渲染下要重新栅格化,差别就在这里。
往下两条路径就合流了,都把 GraphicBuffer 交给 BufferQueue。所以软件渲染限定的只是应用窗口内容的栅格化方式,最终的多图层合成仍然可能由 HWC 或 GPU 完成,这一段跟应用选哪条路无关。
至于 GPU 在像素填充、纹理采样、混合和几何变换上的原生优势,那是这套设计成立的前提,不是它的全部收益。
参考资料与源码
- Android Developers:Analyze with Profile GPU Rendering
- AOSP Android 11:Choreographer.java
- AOSP Android 11:ViewRootImpl.java
- AOSP Android 11:ValueAnimator.java
- AOSP Android 11:AnimationHandler.java
- Android API:ViewTreeObserver.registerFrameCommitCallback
- AOSP:Graphics architecture
- AOSP:BufferQueue and Gralloc
- AOSP:Multiple refresh rate
- Android Developers:Frame rate API