卷 VI · 落地CH 23深度 23/24

怎么看见重跑:三个工具和一条铁律

前面二十二章都在讲「什么时候重跑」。这一章讲怎么把它变成屏幕上能看见的东西——因为在性能这件事上,猜出来的结论有一半是错的,而量一次只要三分钟。

Layout Inspector重组高亮编译器报告跳过不是免费的

先看一个会让你意外的数字

同一个待办屏幕,同一串输入(敲 30 个字),两种 state 组织方式:

                        走过的节点     真的重跑

① 一个大 UiState           150            60
② 拆成几个独立 state         30            30

省掉的遍历:80%,而「真的重跑」的次数只差一倍。

注意这两列的差别。「真的重跑」在两种写法下相差不算大——因为 TopBarTodoList 的参数确实没变,能跳过。

差在「走过」:写法 ① 每敲一个字都要把整棵树走一遍,一路做参数比较、移动 slot 表游标;写法 ② 压根没走到那些节点。

◆ 这一章的铁律

「跳过」不是免费的。

跳过一个节点要做三件事:比较每个参数(可能触发 equals)、检查 scope 脏不脏、把 slot 表游标移到这段 group 的末尾。

单个便宜到可以忽略。但一棵三百个节点的树,每帧走一遍,在低端机上是看得见的。

而这一切的起点只有一句:你在最外层把那个大 State 读出来了(第 6 章)。

工具一:Layout Inspector 的两个计数

这是最直接的一个,而且不需要改任何代码。

怎么读这两个数:

现象说明去看哪一章
两个数都不涨最好的情况:这一轮压根没走到它
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 章那台推断器的官方版本——它给的是权威答案,比任何猜测都准。

✎ 有些 composable 天生不 skippable,这不是问题

返回值不是 Unit 的 composable(比如 @Composable fun rememberXxx(): T)永远不会 skippable——因为跳过了就没法给出返回值。这是正常的。

inline 的 composable(ColumnRowBox)也不会出现在报告里,因为它们被内联进了调用处,没有自己的 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.benchmarkMacrobenchmarkRule 能把它变成一个可回归的数字:

@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,看具体是哪一行占了时间
⚠ 一定要用 release 构建量性能

debug 构建的 Compose 性能比 release 慢好几倍,原因是:

  • 没开 R8,所有代码都在,没内联
  • Compose 运行时在 debug 下有额外的检查和跟踪
  • 没有 Baseline Profile,前几次运行全靠解释执行

所以:用 debug 构建找问题(工具都要 debug),用 release 构建下结论。

顺带一提,加一个 Baseline Profile 通常能让 Compose 页面的首次渲染快 20%–30%,而且不需要改一行业务代码。这是性价比最高的一次性优化,做完再谈别的。

⌗ 到你手上

这一章的一句话

「跳过」不是免费的:同一串输入,一种写法走过 150 个节点,另一种只走 30 个,而真的重跑次数几乎一样。用重组高亮定位、用 Layout Inspector 的两个计数分类、用编译器报告找原因——而且一定要用 release 构建下结论。

最后一章:把这本书压缩成一张表。二十四章的内容,最后能留下的是十二句「先问自己」——每一句都在问同一件事的一个侧面。