4、丢帧与卡顿检测(Jank)
丢帧,也就是 Jank,是 Android 性能优化里最直观的「敌人」。你滑动列表时一顿一顿,动画掉帧,用户马上就能感觉到「这 App 不行」。今天我们就聊聊怎么定义它、怎么精准测量它、以及我用 JankStats API 怎么抓出元凶。
4.1 到底什么是 Jank?
先统一概念:一帧渲染时间如果超过 16ms(60fps)或 8.3ms(120fps),人眼就会察觉到卡顿。我把这种「超时未完成的帧」叫做 Jank。但注意——
- 掉一帧:单个帧超时,画面可能微卡一下。
- 连续丢帧:比如连续 3 帧超过 16ms,用户能明显感到「跳」了一下。
- 冻结帧:超过 700ms 的超级丢帧,几乎算 ANR 前兆了。
💡 我的经验: 别只看平均帧率。平均 55fps 不一定流畅——如果每秒有 5 个连续丢帧,体验依然很差。Jank 率才是硬指标。
4.2 FrameTimeline:系统级的丢帧快照
Android 12 开始,系统通过 FrameTimeline 记录每帧的完整生命周期:从输入事件、布局、绘制到渲染合成。说白了,它能告诉你每一帧到底花在哪了。
你可以在 Perfetto 或 ADB 里直接拉取数据:
adb shell dumpsys gfxinfo <package> framestats
输出的是一堆时间戳,我用 Python 脚本画成折线图。常见的几类结果:
| 帧类别 | 肉眼感知 | 常见原因 |
|---|---|---|
| 正常帧 | 流畅 | 渲染 < 16ms |
| 轻微掉帧 | 几乎不可见 | 布局过度绘制,动画轻抖动 |
| 重大掉帧 | 明显卡顿 | 主线程耗时任务,GC |
| 冻结帧 | 画面定格 | 阻塞 I/O、死锁 |
我记得刚接手一个电商首页时,FrameTimeline 显示每 3 帧就有 1 帧超 20ms。结果发现是列表 Item 里嵌套了一层无用的 ConstraintLayout,布局测量占了 6ms。去掉之后,Jank 率从 30% 降到 5%。
4.3 JankStats API:代码里埋点测量
手动抓 Log 太费劲。Google 在 Jetpack 里推出了 JankStats 库,直接在 App 里注册监听,自动汇报每一帧的丢帧信息。
使用方法很简单:
val jankStats = JankStats.createAndTrack(
window,
thread::looper,
object : JankStats.OnFrameListener {
override fun onFrameData(frameData: FrameData) {
// frameData.durationMs 是这一帧总耗时
// frameData.isJank 表示是否丢帧
// frameData.states 携带了当前 UI 状态标签
}
}
)
// 别忘了在 Activity onPause 时暂停
jankStats.isTrackingEnabled = false
其中 states 是个很实用的东西。你可以给它打标签:
jankStats.addState(“tab”, “首页”)
jankStats.addState(“list”, “商品列表”)
然后在回调里筛选出「首页商品列表」的 Jank 数据。这样做的好处是——你能精准定位到哪个页面、哪个组件在丢帧。
小技巧: 打标签别太泛。我曾经把所有 RecyclerView 都标成 "list",结果发现根本分不清是哪个列表卡。后来按业务分,比如 "home_list"、"cart_list"、"search_list",问题一眼就看出来了。
4.4 解读 JankStats 报告
JankStats 回调里 FrameData 包含几个关键字段:
durationMs:帧耗时,毫秒为单位。isJank:Android 内核通过 vsync 偏差自动判定丢帧。states:你打的状态标签字典。cujType:如果是 “Scroll” 或 “Animation” 等场景,还会额外标记。
我一般会在 release 包接入,只上传 isJank = true 的帧数据到后端,然后按 states 做聚合分析。举个例子:
// 伪代码,按标签统计 Jank 率
val jankByState = mutableMapOf<String, Pair<Int,Int>>()
onFrameData { data ->
if (data.isJank) {
for ((key, value) in data.states) {
val current = jankByState.getOrPut("$key=$value") { 0 to 0 }
jankByState["$key=$value"] = current.first+1 to current.second+1
}
} else {
for ((key, value) in data.states) {
val current = jankByState.getOrPut("$key=$value") { 0 to 0 }
jankByState["$key=$value"] = current.first to current.second+1
}
}
}
然后看哪个标签的 Jank 占比最高——那就是我们的优化目标。
⚠️ 注意: JankStats 只能在 API 23+ 上使用,且需要主线程 Looper 正确。我踩过坑:在子线程更新 UI 导致回调一直不触发,排查了一整天。所以确保所有 UI 操作都在主线程。
另外,JankStats 本身的性能开销很小(约 0.1ms),可以常驻线上。但要注意别在回调里做耗时操作,它也是主线程调用的,会导致自增丢帧——那可就尴尬了。
4.5 一张图看懂丢帧检测体系
4.6 避坑实战小结
讲几个我亲历的坑:
- Jank 率 ≠ 0:别追求无丢帧。60fps 下偶尔掉一帧正常,只要 Jank 率低于 10% 且不连续,用户基本无感。
- 低端机 vs 旗舰机:我曾经在骁龙 8 Gen2 上测出 0.1% Jank 率,同一版本跑到红米 Note 上飙到 35%。所以基线测试一定要在中低端机上跑。
- GC 引起的丢帧:JankStats 不会告诉你丢帧是因为 GC。你需要结合 Perfetto 里的 “mark sweep” 事件来交叉验证。
🔍 一句话总结: FrameTimeline 让你看到「帧长什么样」,JankStats 让你知道「哪个模块在卡」。两者配合,基本能搞定 80% 的丢帧问题。