跳过:Compose 凭什么敢不重跑
「参数没变就跳过」听起来天经地义。但「没变」这两个字,在一门允许你随便改对象的语言里并不好定义——Compose 为此发明了一套稳定性推断,然后在 2024 年把它的后果整个换掉了。
「没变」为什么是个难题
Compose 想跳过一次调用,得先确认一件事:这次的参数和上次一样吗?
你可能觉得这有什么难的,equals 一下不就行了。问题在于,equals 相等只能说明此刻它们看起来一样,不能说明这个对象以后不会自己变。
class Cart {
var items: MutableList<Item> = mutableListOf()
}
// 传进去的还是同一个 Cart 对象,equals 也相等
CartView(cart)
// 但别的地方悄悄干了这个:
cart.items.add(newItem) // ← 没有任何人被通知
如果 Compose 因为「参数相等」跳过了 CartView,那用户就永远看不到新加的那一项。界面和数据脱钩了——这正是声明式 UI 最不能出的错。
所以 Compose 需要的不是「相等」,是一个更强的保证:
「这个类型,要么永远不会变, 要么它变了会自己通知 Compose。」 满足这个条件的类型,叫做稳定(stable)。
推断规则很短
编译器判定一个类稳不稳定,规则只有四条,按顺序走:
- 标了
@Stable或@Immutable→ 稳定(编译器不检查,这是你的承诺) - 有任何一个
var属性 → 不稳定 - 有任何一个属性的类型不稳定 → 不稳定
- 否则 → 稳定
加上一张内建类型表:基本类型、String、枚举、函数类型稳定;List/Set/Map 这些接口不稳定(编译器不知道你传的实现能不能变);来自其它模块、没被 Compose 编译器编译过的类型,一律按不稳定处理。
下面这台推断器就是照这几条写的,你可以对着看每一条的理由:
那个开关是这一章的主角,先别急着拨。
先说不开强跳过时的世界(也就是 2024 年之前)
规则很粗暴:参数表里只要有一个不稳定的类型,这个 composable 就永远不能跳过——不管值变没变。
而 List 是不稳定的。于是这个再普通不过的写法:
data class UiState(
val loading: Boolean,
val items: List<Todo> // ← 就这一个字段,整个类不稳定
)
@Composable
fun TodoList(state: UiState) { … } // ← 永远不跳过
就意味着 TodoList 每次父级重组都要跟着跑一遍,连带里面所有东西。这就是那几年满屏 @Immutable 的来历:
@Immutable // 「我保证不变,你信我」
data class UiState(
val loading: Boolean,
val items: List<Todo>
)
或者引入 kotlinx.collections.immutable,把 List 换成 ImmutableList。或者写一个 compose_compiler_config.conf 把第三方类型列进白名单。三种办法都能用,三种都是为了绕过一个类型系统的局限而做的体操。
编译器不会验证你的承诺。你在一个持有 MutableList 的类上标 @Immutable,编译一样过,然后你会得到一个「数据改了界面不动」的 bug,而且极难查——因为看代码一切正常。
标它之前问自己一句:这个对象被传出去之后,有没有任何一条路径能改到它内部的东西?只要答案不是「绝对没有」,就别标。
强跳过改了什么
现在把 demo 里那个开关打开,看下面那张表怎么变。
强跳过(strong skipping)从 Compose 编译器 2.0.20 起默认开启。它做了两件事:
| 没有强跳过 | 有强跳过(现在的默认) | |
|---|---|---|
| 稳定参数 | 用 equals 比 | 用 equals 比(没变) |
| 不稳定参数 | 一票否决,整个函数不能跳过 | 改用 引用相等(===)比 |
| lambda 参数 | 要你自己 remember 才不会每次都是新对象 | 编译器自动帮你记住(自动记忆化) |
第一行的后果很大:TodoList(state) 现在能跳过了——只要你传的还是同一个 UiState 对象。
强跳过没有消灭「不稳定」这件事,它只是换了个问法:
- 以前的问题是:「这个类型稳定吗?」——答案在类型声明里
- 现在的问题是:「这次传进去的,还是不是上次那个对象?」——答案在你有没有
remember住它
这是一次从「类型问题」到「身份问题」的转移。它让 90% 的 @Immutable 可以删掉,但也带来了一类新的坑:每次都新建一个对象。
新的坑:每次都新建一个对象
看这两行,它们的差别在强跳过下会被放大:
// ❌ 每次组合都造一个新 List,引用永远不同 → 永远跳不过
TodoList(items = state.items.filter { !it.done })
// ✅ 记住它,引用不变 → 能跳过
val visible = remember(state.items) { state.items.filter { !it.done } }
TodoList(items = visible)
同样的道理适用于:.map { }、.sorted()、listOf(a, b)、Modifier.padding(x)(这个 Compose 内部有优化)、以及任何在参数位置上现场构造的东西。
demo 表格的第二行就是这个:「参数是 List<Todo>,每次 map 出一个新 List」——强跳过开着也跳不过去,因为引用真的变了。
demo 表格的第三行:ImmutableList<Todo>,内容一样但换了新对象——能跳过。
因为它是稳定类型,稳定类型走的是 equals,而 equals 比的是内容。
所以 kotlinx.collections.immutable 在强跳过时代仍然有它的位置:当你确实每次都会产生新集合、但内容常常没变的时候,它比 remember 更省心。代价是多一个依赖,以及所有集合操作都要走它的 API。
lambda 那一半
强跳过还顺手解决了一个更隐蔽的问题。看这行:
TodoRow(item = item, onClick = { vm.toggle(item.id) })
这个 lambda 捕获了 item,所以每次重组都是一个新的对象。在强跳过之前,你得手写:
val onClick = remember(item.id) { { vm.toggle(item.id) } } // 丑,但必要
现在编译器会自动帮你做这件事(自动记忆化),只要捕获的值本身是稳定的。所以这类 remember { { … } } 也可以删了。
但有一个例外要记住:捕获了不稳定对象的 lambda,编译器不敢帮你记住。这种时候老办法还是有用的。
先确认自己在哪个世界。检查项目的 Kotlin 版本,以及有没有人手动关掉它:
build.gradle.kts(模块级)
composeCompiler {
// 有这一行说明被手动关了,删掉它就是默认值(开启)
// featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled())
// 想看到编译器对每个 composable 的判定,打开报告:
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
编译一次之后,去 compose_reports 里看那个 *-composables.txt:每个 composable 前面会标着 restartable/skippable,每个参数前面标着 stable/unstable。这是最权威的答案,比任何猜测都准。
然后做两件事:
- 搜一遍项目里的
@Immutable和@Stable,逐个问「删了它还 skippable 吗」——报告会告诉你 - 搜一遍参数位置上的
.map {、.filter {、.sorted(),这些是强跳过时代真正的性能问题
什么时候该让它别跳过
最后一个反方向的问题:有没有「我就是要它每次都跑」的场景?
有,而且 Compose 给了工具:
// 每次赋值都通知,哪怕值相等
val trigger = remember { mutableStateOf(0, neverEqualPolicy()) }
但坦白说,你几乎不需要它。如果你发现自己想强制重组,多半是状态建模出了问题——比如把「一次性事件」塞进了 State(第 21 章会专门算这笔账)。
这一章的一句话
「稳定」的意思是「要么不变、要么变了会通知」。强跳过把「一个不稳定参数就一票否决」换成了「不稳定的用引用比」——问题从「类型对不对」变成了「你有没有把它记住」。
卷 I 到此结束:你现在知道了重跑从哪里开始(脏 scope)、影响哪几个阶段、什么活得下来(slot 表)、什么时候能不跑(跳过)。
下一卷回到最上游:那个「变了会通知」的 State,它自己到底是个什么东西?它不是变量,也不是 LiveData——它是一条记录链,而这条链解释了为什么 Compose 敢让你在别的线程上改数据。