卡顿理论基础:帧率、掉帧与 Jank

聊 SystemUI 卡顿之前,咱得先搞清楚一个基本问题:到底什么是“卡”?你用手指滑屏,或者点一个通知,系统要花多长时间才能把画面更新到屏幕上?这个时间决定了流畅不流畅。

我经常跟团队里刚入行的同学说,不要一上来就堆优化手段。你先得理解几个核心指标——帧率、掉帧、Jank。说白了,这三兄弟就是卡顿的“温度计”和“听诊器”。

帧率:流畅度的基准线

帧率,就是每秒刷新的画面数量。单位 fps(frames per second)。

Android 显示系统一般跑在 60fps 或 120fps(高刷屏)。60fps 意味着每 16.67ms 就得交一帧。如果每帧都能按时交付,用户看到的画面就是平滑的。

但注意,帧率不是越高越好。举个例子,你屏幕是 120Hz,但应用只跑到 60fps,画面照样会有抖动感。为什么呢?因为显示器和渲染节奏不一致。所以衡量流畅度,我们更关心“帧率稳定性”而不是“最高帧率”。

我的经验:我在调 SystemUI 的下拉通知栏动画时,发现即使平均帧率有 55fps,用户照样觉得一顿一顿的。后来才发现,帧率波动大才是罪魁祸首。帧率曲线像心电图一样上蹿下跳,比稳定 30fps 的观感还差。

掉帧:一次失败的交付

掉帧,就是某帧没能在规定时间内完成渲染并送出。对于 60Hz 屏幕,如果一帧花了 20ms,那屏幕只能重刷上一帧,这一帧就被“丢”了。

掉帧的根因,往往是单个帧的渲染耗时超过了 vsync 间隔。你想想看,CPU/GPU 忙不过来,或者主线程被阻塞了,就会掉帧。

单次掉帧通常不明显,连续掉帧就会形成可见的卡顿。

Jank:用户感知的卡顿

Jank 比掉帧更“用户侧”。它不是技术上的丢帧,而是用户主观感觉画面不流畅、有跳跃感。

Google 在 Jank 检测工具里给过一个定义:当某帧的渲染时长超过前一个 vsync 周期的 1.5 倍,或者两帧之间的间隔大于 32ms,就算一次 Jank。

说白了,Jank 就是掉帧的“累积效应”。一次掉帧可能不卡,但连续掉帧、或者突然跳变,用户就感觉到了。

指标定义衡量方式用户感受
帧率 (fps)每秒成功显示的帧数统计 1s 内帧数平均流畅感
掉帧 (Dropped Frame)单帧未按时交付vsync 周期内未完成偶尔轻微顿
Jank明显卡顿事件(帧间隔突变)帧间间隔 > 32ms 或 > 1.5x vsync明显卡顿,滑不动

卡顿的常见原因

我在 SystemUI 项目里踩过的坑,总结起来无非这几类:

1. 主线程堵塞

UI 更新都在主线程。一旦主线程做了太重的任务——比如解析 XML、加载大图、执行复杂的 Binder 调用——渲染就会被卡住。我记得有一次,用户反馈下拉通知栏按了以后要等 1 秒才弹出来。抓 trace 一看,主线程正在同步地读一个 2MB 的 SharedPreference 文件……

2. 过度绘制(Overdraw)

同一个像素被画了多次。通知栏的阴影、圆角、背景层叠,特别容易产生过度绘制。GPU 需要多画一遍,渲染时间自然拉长。你可以用 GPU 过度绘制调试工具看,红色区域越多越危险。

3. Binder 调用或 IPC 延迟

SystemUI 经常和系统服务通信,比如 StatusBarManager、NotificationManager、WindowManager。这些跨进程调用如果被阻塞或排队,就会让 UI 线程空等。我曾经见过一次 case:系统服务端锁竞争严重,导致 Binder 调用耗时从 2ms 飙到 50ms。

4. 布局复杂/嵌套太深

ConstraintLayout 虽然强大,但嵌套太多层或者用了太多的 weight、自适应属性,测量(measure)阶段就会变慢。Android 每帧的 measure/layout/draw 三个阶段,任何一个阶段耗时过长都会导致掉帧。

5. 动画执行器竞争

SystemUI 上多个动画同时在跑——状态栏图标动画、通知展开动画、导航栏动画——如果它们没用同一个 Choreographer 或者线程调度被抢占,就会出现动画互相掐架,帧率被拖垮。

6. 内存抖动与 GC

每帧创建大量临时对象,触发 GC。GC 是 STW(Stop The World)的,哪怕只有 3-5ms 的暂停,也足够让一帧超时。我见过最夸张的,一个动画每帧 new 了 30 个 Rect 对象,GC 频率高到一秒钟触发 3 次,帧率直接掉到 20fps。

7. CPU 频率/大小核调度

系统降频或者把 UI 线程放在小核上跑,渲染速度就会慢。我调一个车载 SystemUI 的时候,发现温控策略把大核锁在了 1.2GHz,动画直接变成幻灯片。

核心总结:所有的卡顿,归根到底都可以用“一帧超时”来解释。而“一帧超时”的根因,要么是主线程忙,要么是 GPU/CPU 输出跟不上,要么是同步阻塞。

如何发现卡顿?

别凭感觉。我建议你上工具:

  • Systrace / Perfetto:看每一帧的渲染 pipeline,哪个阶段超时
  • GPU Profiling(adb shell dumpsys gfxinfo):逐帧耗时,一目了然
  • Choreographer 回调:在 doFrame 里记录帧间隔
  • FrameMetrics:Android 8.0+ 提供,可以精确统计 Jank 次数

下面我画了一张图,把刚才讲的核心概念串起来——从“理想帧”到“掉帧”再到“Jank”,以及常见的几个卡温源头。你看完应该能有个整体框架。

卡顿理论基础:从帧到Jank 理想帧 (≤16.67ms) 掉帧 ( > 16.67ms ) Jank (用户感知卡顿) 常见原因(分类) 主线程拥堵 (XML解析/大图加载/Binder) 过度绘制/布局复杂 (Overdraw/嵌套深度) IPC/Binder 延迟 (系统服务锁竞争) 内存抖动/GC (每帧创建临时对象) CPU调度/降频 (大小核/温控策略) 动画执行器竞争 (多Choreographer冲突) 导致 诊断工具:Systrace / Perfetto / dumpsys gfxinfo / Choreographer 关注点:单帧耗时、帧间隔、vsync延迟、主线程锁

避坑指南:我曾经犯过一个错——看到帧率有 50fps 就觉得流畅度没问题。后来给用户做了盲测,才发现同样 50fps,抖动大的方案评分比稳定 35fps 的还低。嗯,这之后我再也不单看帧率了。Jank 次数和帧间隔分布才是王道。

好了,这一章我们把概念打清楚了。接下来你就知道,优化不是追求跑满 120fps,而是消灭 jank、稳定帧间隔。记住:用户不是实验室仪器,他们感知的是“跳跃感”,不是“平均帧率”。