帧率崩溃现象:帧率(Hz)与掉帧(Jank)概念、Choreographer与VSync机制

说帧率崩溃之前,咱得先搞清楚两个基础概念——帧率和掉帧。这两个词我面试时经常追问,很多人答得模棱两可。其实没那么玄乎,咱们用大白话捋一遍。

帧率(FPS)——屏幕刷新的“心跳”

帧率就是每秒刷新的画面数,单位Hz。60Hz代表每秒刷60张图,120Hz就是120张。人眼看连续画面有个门槛——大概24帧以上就觉得流畅了。但手机触控和动画不同,低于30帧就明显卡顿,低于20帧基本没法用。

我在项目中遇到过一台千元机,跑一个简单列表滑动,帧率直接掉到15Hz。用户反馈说“划一下像PPT翻页”,其实就是帧率跌破了人眼忍耐极限。

关键指标:
  • 理想帧率:60Hz(普通屏)或120Hz(高刷屏)
  • 可接受帧率:30Hz以上
  • 危险帧率:低于24Hz,明显卡顿

掉帧(Jank)——帧率不稳才是真凶

掉帧不是帧率变低,而是帧与帧之间间隔不匀。你想想看,60Hz的屏幕,每16.6ms要刷一帧。如果某帧花了30ms才准备好,后面那帧就得等下一轮VSync,这一等就是33.2ms了。中间缺了一帧,肉眼就察觉到了“跳一下”。

Jank的定义业内其实有标准:连续两帧之间的间隔超过1.5倍正常帧间隔就算一次掉帧。以60Hz为例,正常间隔16.6ms,如果某帧耗时超过25ms,就算Jank。

帧率正常帧间隔Jank阈值(1.5倍)
30Hz33.3ms50ms
60Hz16.6ms25ms
90Hz11.1ms16.7ms
120Hz8.3ms12.5ms

曾经我排查一个电商App的卡顿问题,用Systrace抓了一圈,发现每次用户点击“加入购物车”后,帧率没怎么掉,但Jank次数暴增三倍。后来定位到后台开了一个定时器,每帧都做一次网络统计,把主线程占住了几毫秒。就这几毫秒,导致了频繁掉帧。

Choreographer——Android的“帧调度师”

Choreographer是Android框架层的一个核心类,负责协调UI渲染和VSync信号。说白了,它就是一个排队叫号系统:VSync信号来了,Choreographer按顺序叫“输入处理”、“动画”、“布局”、“绘制”这四个步骤去执行。

整个流程大概是这样的:

  1. 硬件VSync信号触发
  2. Choreographer收到信号,开始一轮FrameRequest
  3. 依次回调:onInputEvent → onAnimation → onTraversal → onDraw
  4. 完成后把数据提交给SurfaceFlinger合成

如果某个步骤超时(比如onAnimation里跑了个高耗时任务),Choreographer不会等你,直接跳过当前帧。这就是掉帧的直接原因。我习惯用adb shell dumpsys gfxinfo来抓Choreographer的调度延迟,非常直观。

常见误区:很多人以为主线程不卡就不会掉帧。实际上,只要Choreographer的四个回调中任何一个超过VSync间隔,就会丢帧。即使主线程没有ANR,也可能因为微小的延迟积累导致严重Jank。

VSync机制——硬件和软件的“握手协议”

VSync(垂直同步)是屏幕硬件发出的一个脉冲信号,告诉GPU和CPU:“我已经刷完上一帧了,你可以准备下一帧了。”Android从4.1开始引入VSync驱动的渲染模型,所有UI绘制都必须等到VSync信号到来才启动。

这样做的好处是避免“撕裂”——GPU还没画完就往屏幕写数据,画面上会有一条割裂线。但坏处也很明显:如果CPU/GPU不能在下一个VSync前完成工作,就只能等到再下一个VSync,这样就浪费了一整个刷新周期。

我做个比喻:VSync就像火车站的发车铃。铃响了,所有乘客(渲染任务)必须上车。如果有人没来得及,只能等下一趟车。但下一趟车要16.6ms后才来。所以一次掉帧,代价就是至少16.6ms的空白。

Backpressure(背压)——帧率崩溃的连锁反应

当掉帧发生时,系统会启动一种叫Backpressure的机制,说白了就是“别再塞了,我忙不过来”。Choreographer会暂停接收新的输入事件,直到当前帧完成。但这时候后台任务如果还在抢CPU,会导致更严重的卡顿,形成恶性循环。

我个人曾经调试过一个直播App,主播端频繁掉帧,观众端反馈黑屏。抓日志发现是后台一个图片上传线程把CPU跑满了,导致Choreographer的输入回调被延迟了30ms以上。每帧都掉一次Jank,帧率直接崩到10Hz。嗯,解决办法就是把上传线程的优先级降到了THREAD_PRIORITY_BACKGROUND。

避坑指南:我曾经把一个后台日志打印放在onAnimation回调里,结果release包数据量一大,每帧都多花5ms打印时间,导致列表滑动时掉帧30%。后来全部改到工作线程异步写日志,问题解决。
记住:Choreographer回调里不要有任何I/O操作、锁等待、复杂计算。你哪怕只是写一行log,都有可能成为压垮帧率的最后一根稻草。
帧率崩溃与Choreographer+VSync机制 帧率崩溃核心机制流程图 硬件VSync信号 Choreographer接收 Input Animation Traversal Draw 提交SurfaceFlinger 超时→掉帧(Jank) Backpressure背压 后台任务抢占CPU 关键结论: • 一次掉帧至少浪费16.6ms(60Hz下),Backpressure会进一步放大卡顿 • 后台任务超时会抢CPU→导致Choreographer回调延迟→帧率崩溃→ANR风险上升

实战:如何用Choreographer定位帧率崩溃

我在项目里常用的排查手段就三个:

  1. Systrace(现已改名为Perfetto)——最直接的武器。在Systrace里能看到Choreographer的FrameMissed标记,以及每个回调的耗时。
  2. Choreographer.FrameCallback——自己注册一个回调,记录每次doFrame的时间戳,就能算出掉帧分布。
  3. gfxinfo dumpsys——快速查看历史帧率统计,适合线上问题收敛。
排查思路:
1. 先抓全局帧率,看最低点出现在哪个场景
2. 再抓Choreographer回调耗时,看哪个阶段最慢
3. 最后看后台线程CPU占用,找出抢CPU的任务
4. 优先解决后台任务超时问题(比如用WorkManager替代定时器)

有一次用户反馈“杀后台后App卡成PPT”,我抓了Perfetto发现:杀掉所有App后,系统会立即触发大量缓存刷新任务,这些任务全部挤在Choreographer的动画回调之后执行,直接导致下一帧的输入回调延迟了40ms。嗯,说白了就是后台任务“抢跑”,干扰了VSync节奏。

总结下来,帧率崩溃往往不是单个点的问题。它涉及硬件VSync、Choreographer调度、后台任务三者的协同。任何一个环节慢了,其他环节就会连锁崩盘。我们做优化时,要把这三块当成一个整体来看,不能只盯着UI线程。

个人经验:我习惯在每一帧的动画回调里加一个极简的计时判断——如果当前帧已经超过VSync间隔的80%,就主动跳过非必须的UI更新,保帧率优先。这叫“优雅降级”,比等系统掉帧强得多。