怎么看见重跑:三个工具和一条铁律
前面二十二章都在讲「什么时候重跑」。这一章讲怎么把它变成屏幕上能看见的东西——因为在性能这件事上,猜出来的结论有一半是错的,而量一次只要三分钟。
先看一个会让你意外的数字
同一个待办屏幕,同一串输入(敲 30 个字),两种 state 组织方式:
走过的节点 真的重跑 ① 一个大 UiState 150 60 ② 拆成几个独立 state 30 30 省掉的遍历:80%,而「真的重跑」的次数只差一倍。
注意这两列的差别。「真的重跑」在两种写法下相差不算大——因为 TopBar、TodoList 的参数确实没变,能跳过。
差在「走过」:写法 ① 每敲一个字都要把整棵树走一遍,一路做参数比较、移动 slot 表游标;写法 ② 压根没走到那些节点。
「跳过」不是免费的。
跳过一个节点要做三件事:比较每个参数(可能触发 equals)、检查 scope 脏不脏、把 slot 表游标移到这段 group 的末尾。
单个便宜到可以忽略。但一棵三百个节点的树,每帧走一遍,在低端机上是看得见的。
而这一切的起点只有一句:你在最外层把那个大 State 读出来了(第 6 章)。
工具一:Layout Inspector 的两个计数
这是最直接的一个,而且不需要改任何代码。
Android Studio ▸ 运行 debug 构建
▸ 右下角 Layout Inspector(或 View ▸ Tool Windows)
▸ 左侧选中一个 composable
▸ 右侧属性面板顶部:
Recomposition count ← 这一章的「真的重跑」
Skip count ← 这一章的「走过但跳过」
怎么读这两个数:
| 现象 | 说明 | 去看哪一章 |
|---|---|---|
| 两个数都不涨 | 最好的情况:这一轮压根没走到它 | — |
| Skip 涨得快,Recomposition 不涨 | 它的父级在频繁重跑,它被反复「走过」 | 第 6 章:把读取推下去 |
| Recomposition 涨得快 | 它自己读的 state 在高频变,或者参数每次都是新对象 | 第 4 章、第 20 章 |
| 某一行 Recomposition 特别高 | 列表没写 key,或者 item 每次新建 | 第 22 章 |
第二行是最容易被忽略的一种,因为「Recomposition count 是 0」看起来完全健康。但 Skip count 每秒涨几十,说明有人在替它承担重跑。
工具二:重组高亮
Layout Inspector 里有一个开关,打开之后重组过的区域会在屏幕上闪一下,颜色随重组次数从绿变红。
它的价值不在于精确,在于快:滑动一下屏幕,哪块在闪一眼就看到了。特别适合找这两类问题:
- 整屏都在闪 → 有个高频 state 被在最外层读了
- 一直在闪,你什么都没做 → 无限重组(第 18 章那个「在组合里改状态」)
找到大概位置之后,再用工具一去看具体数字。
工具三:编译器报告
前两个看的是运行时,这个看的是编译期——它能告诉你每个 composable 到底能不能跳过,以及为什么不能。
// build.gradle.kts(模块级)
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
编译一次之后,去 build/compose_reports/ 看那个 *-composables.txt:
# 好的:能重启、能跳过 restartable skippable fun TodoRow( stable item: Todo stable onClick: Function0<Unit> ) # 有问题:能重启,但不能跳过 restartable fun TodoList( unstable items: List<Todo> )
关键词只有三个:
restartable——它有自己的 RecomposeScope,能被单独重跑。绝大多数 composable 都有skippable——参数没变就能跳过。这是你要盯的那个stable/unstable——每个参数的稳定性判定
再看一眼 *-classes.txt,它会列出每个类的稳定性判定以及原因。这是第 4 章那台推断器的官方版本——它给的是权威答案,比任何猜测都准。
返回值不是 Unit 的 composable(比如 @Composable fun rememberXxx(): T)永远不会 skippable——因为跳过了就没法给出返回值。这是正常的。
inline 的 composable(Column、Row、Box)也不会出现在报告里,因为它们被内联进了调用处,没有自己的 scope。这也是为什么把内容包一层 Column 并不会帮你隔离重组——要隔离必须抽成一个非 inline 的函数。
还有两个不常被提到的手段
Composition Tracing:在 Systrace 里看到 composable 名字
默认的系统跟踪里只能看到 Compose:recompose 这种笼统的段。加上这两个依赖之后,每个 composable 的名字会出现在时间线上:
// debug 构建才加
implementation("androidx.compose.runtime:runtime-tracing:…")
implementation("androidx.tracing:tracing-perfetto:…")
然后用 Android Studio 的 Profiler 抓一段系统跟踪,就能看到具体是哪个 composable 占了那一帧的时间。这是「知道哪里慢」到「知道慢在哪一行」的最后一步。
Benchmark:把结论钉死
前面所有工具都是「看一眼」,而 androidx.benchmark 的 MacrobenchmarkRule 能把它变成一个可回归的数字:
@Test fun scrollFeed() = benchmarkRule.measureRepeated(
packageName = "…",
metrics = listOf(FrameTimingMetric()),
iterations = 10,
setupBlock = { startActivityAndWait() }
) {
device.findObject(By.res("feed")).fling(Direction.DOWN)
}
它会输出 frameDurationCpuMs 的 P50/P90/P99。P99 是那个真正重要的数——它代表最卡的那 1% 的帧,也就是用户实际感觉到的卡顿。
一条排查顺序
发现某个页面卡的时候,按这个顺序走,通常十分钟内能定位:
① 打开重组高亮,滑一下
整屏在闪 → 去第 6 章:谁在最外层读了高频 state
某一块在闪 → 记住是哪一块,进入 ②
什么都没闪 → 问题不在重组,去看布局/绘制(第 2 章)或者主线程(第 12 章)
② Layout Inspector 看那一块的两个计数
Recomposition 高 → 第 4 章(参数每次新对象?)、第 20 章(要不要 derivedStateOf)
Skip 高 → 第 6 章(父级在替它挨打)
③ 看编译器报告
那个 composable 是 skippable 吗?
不是 → 哪个参数 unstable?为什么?
④ 还没解决 → Composition Tracing,看具体是哪一行占了时间
debug 构建的 Compose 性能比 release 慢好几倍,原因是:
- 没开 R8,所有代码都在,没内联
- Compose 运行时在 debug 下有额外的检查和跟踪
- 没有 Baseline Profile,前几次运行全靠解释执行
所以:用 debug 构建找问题(工具都要 debug),用 release 构建下结论。
顺带一提,加一个 Baseline Profile 通常能让 Compose 页面的首次渲染快 20%–30%,而且不需要改一行业务代码。这是性价比最高的一次性优化,做完再谈别的。
今天就能配好的三件事:
1. build.gradle.kts 里加上 composeCompiler 的 reports/metrics
编译一次,把 *-composables.txt 通读一遍
—— 你会立刻发现几个自己以为能跳过、其实不能的函数
2. 加一个 Baseline Profile
androidx.baselineprofile 插件,跑一次生成
—— 首屏渲染直接快一档
3. 给最重要的那个滚动页面写一个 Macrobenchmark
—— 之后每次改动都有个数字可以对比,
不用再靠「感觉好像流畅了」
这一章的一句话
「跳过」不是免费的:同一串输入,一种写法走过 150 个节点,另一种只走 30 个,而真的重跑次数几乎一样。用重组高亮定位、用 Layout Inspector 的两个计数分类、用编译器报告找原因——而且一定要用 release 构建下结论。
最后一章:把这本书压缩成一张表。二十四章的内容,最后能留下的是十二句「先问自己」——每一句都在问同一件事的一个侧面。