卷 I · 重跑CH 04深度 4/24

跳过:Compose 凭什么敢重跑

「参数没变就跳过」听起来天经地义。但「没变」这两个字,在一门允许你随便改对象的语言里并不好定义——Compose 为此发明了一套稳定性推断,然后在 2024 年把它的后果整个换掉了。

稳定性@Immutable强跳过lambda 捕获

「没变」为什么是个难题

Compose 想跳过一次调用,得先确认一件事:这次的参数和上次一样吗?

你可能觉得这有什么难的,equals 一下不就行了。问题在于,equals 相等只能说明此刻它们看起来一样,不能说明这个对象以后不会自己变

class Cart {
    var items: MutableList<Item> = mutableListOf()
}

// 传进去的还是同一个 Cart 对象,equals 也相等
CartView(cart)

// 但别的地方悄悄干了这个:
cart.items.add(newItem)     // ← 没有任何人被通知

如果 Compose 因为「参数相等」跳过了 CartView,那用户就永远看不到新加的那一项。界面和数据脱钩了——这正是声明式 UI 最不能出的错。

所以 Compose 需要的不是「相等」,是一个更强的保证:

「这个类型,要么永远不会变,
  要么它变了会自己通知 Compose。」

满足这个条件的类型,叫做稳定(stable)

推断规则很短

编译器判定一个类稳不稳定,规则只有四条,按顺序走:

  1. 标了 @Stable@Immutable稳定(编译器不检查,这是你的承诺)
  2. 有任何一个 var 属性 → 不稳定
  3. 有任何一个属性的类型不稳定 → 不稳定
  4. 否则 → 稳定

加上一张内建类型表:基本类型、String、枚举、函数类型稳定;ListSetMap 这些接口不稳定(编译器不知道你传的实现能不能变);来自其它模块、没被 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 把第三方类型列进白名单。三种办法都能用,三种都是为了绕过一个类型系统的局限而做的体操

⚠ @Immutable 是承诺,不是检查

编译器不会验证你的承诺。你在一个持有 MutableList 的类上标 @Immutable,编译一样过,然后你会得到一个「数据改了界面不动」的 bug,而且极难查——因为看代码一切正常。

标它之前问自己一句:这个对象被传出去之后,有没有任何一条路径能改到它内部的东西?只要答案不是「绝对没有」,就别标。

强跳过改了什么

现在把 demo 里那个开关打开,看下面那张表怎么变。

强跳过(strong skipping)从 Compose 编译器 2.0.20 起默认开启。它做了两件事:

没有强跳过有强跳过(现在的默认)
稳定参数equalsequals 比(没变)
不稳定参数一票否决,整个函数不能跳过改用 引用相等===)比
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 版本,以及有没有人手动关掉它:

编译一次之后,去 compose_reports 里看那个 *-composables.txt:每个 composable 前面会标着 restartableskippable,每个参数前面标着 stableunstable。这是最权威的答案,比任何猜测都准。

然后做两件事:

  1. 搜一遍项目里的 @Immutable@Stable,逐个问「删了它还 skippable 吗」——报告会告诉你
  2. 搜一遍参数位置上的 .map {.filter {.sorted(),这些是强跳过时代真正的性能问题

什么时候该让它别跳过

最后一个反方向的问题:有没有「我就是要它每次都跑」的场景?

有,而且 Compose 给了工具:

// 每次赋值都通知,哪怕值相等
val trigger = remember { mutableStateOf(0, neverEqualPolicy()) }

但坦白说,你几乎不需要它。如果你发现自己想强制重组,多半是状态建模出了问题——比如把「一次性事件」塞进了 State(第 21 章会专门算这笔账)。

这一章的一句话

「稳定」的意思是「要么不变、要么变了会通知」。强跳过把「一个不稳定参数就一票否决」换成了「不稳定的用引用比」——问题从「类型对不对」变成了「你有没有把它记住」。

卷 I 到此结束:你现在知道了重跑从哪里开始(脏 scope)、影响哪几个阶段、什么活得下来(slot 表)、什么时候能不跑(跳过)。

下一卷回到最上游:那个「变了会通知」的 State,它自己到底是个什么东西?它不是变量,也不是 LiveData——它是一条记录链,而这条链解释了为什么 Compose 敢让你在别的线程上改数据。