炉 III · 塑形CH 13进度 13/26

不可变:copy 的陷阱,与 Compose 的帧率

「不可变更好」是一句人人点头、但没人说得清收益的话 —— 直到你用 Compose。在 Compose 里,不可变不是洁癖,是帧率:一个参数被判定为「不稳定」的 Composable,输入没变也会重组;而你的状态类里只要有一个 List 字段,它就可能是不稳定的。这一章先拆三个真实的坑(val 不等于不可变、copy() 是浅拷贝、嵌套更新会写成五层 copy),再把「不可变」换算成 Compose 里能用 Layout Inspector 数出来的重组次数。

copy 浅拷贝PersistentListCompose 稳定性强跳过

坑一:val 不等于不可变

data class State(val items: MutableList<Item>)      // val ✓ 但是……

val s = State(mutableListOf())
s.items.add(Item())        // ✓ 完全合法!内容变了,而 s 还是那个 s

val 保证的是「引用不变」,不是「内容不变」。这两件事差得很远。上面那个 State 顶着 data class 的外表,实际上是个彻头彻尾的可变对象 —— 它的 equals 会因为内容变化而变化,它没法安全地在线程间共享,它在 Compose 里会让重组判断失效。

规则:状态类里的集合,类型永远写 List / Map / Set,不写 Mutable*—— 但注意,这只是「不给你 add 方法」,底层那个对象依然可能是 ArrayList

val list: List<Item> = mutableListOf(a, b)      // 静态类型只读
(list as MutableList).add(c)                    // 但它底下就是个 ArrayList,转型就能改

Kotlin 的 List只读接口(read-only),不是不可变(immutable) —— 这个区别在 Compose 的稳定性判断里会咬人(下面就讲)。想要真正的不可变,需要 kotlinx.collections.immutable

坑二:copy() 是浅拷贝

data class Profile(val name: String, val tags: MutableList<String>)

val a = Profile("harry", mutableListOf("kotlin"))
val b = a.copy(name = "gao")     // 「拷贝」了一份

b.tags.add("android")
println(a.tags)                  // [kotlin, android] —— a 也变了!

copy() 只复制引用ab 共享同一个 tags 列表。

这个 bug 在 MVI 架构里特别致命:你以为每次 state.copy(...) 都产出了一个全新的、独立的状态快照(时间旅行、状态回放全靠这个假设),实际上新旧状态共享着内部的可变对象 —— 于是「历史状态」会被后来的修改偷偷改写

解法:让内部的东西也不可变。一旦 tags 是真正不可变的,浅拷贝就是安全的 —— 因为没有人能改它。不可变让「共享」变得安全,这正是它最大的收益。

坑三:嵌套更新的五层 copy

// 我只想把用户所在公司的地址的邮编改一下
val newState = state.copy(
    user = state.user.copy(
        company = state.user.company.copy(
            address = state.user.company.address.copy(
                zipCode = "1010"          // ← 就为了这一行
            )
        )
    )
)

不可变的代价在这里达到顶峰。而且这段代码还很容易写错(复制粘贴时改错一层,编译器完全不会拦你)。

三条出路:

  • 压平你的状态。八成情况下,嵌套四层的状态模型本身就有问题 —— 它把「数据的存储结构」和「UI 需要的结构」混为一谈了。
  • 写领域方法。fun State.withZip(z: String): State = ... —— 把丑陋收在一个地方,调用点保持干净。
  • 用光学器件(Optics)。Arrow 的 @optics 能生成「镜头(Lens)」,让你写成 State.user.company.address.zipCode.set(state, "1010")。第 21 章会讲 —— 但只有当你真的深受其苦时才值得引入

然后是收益:Compose 的重组

现在讲那个把「不可变」从美学变成工程的东西。

Compose 的重组引擎有一条核心规则:如果一个 Composable 的所有参数都是「稳定的」,且它们的值没变,就整个跳过重组。

什么叫「稳定(stable)」?编译器的判断标准是:

  • 它的 equals 结果永远一致(同样两个实例,比较结果不会变);
  • 它的公开属性变化时会通知 ComposeMutableState 那种),或者压根不会变

于是:val 的 data class,字段都是基本类型/String → 稳定。而 List 字段 → 编译器判定为不稳定 —— 因为如上所述,List 只是个只读接口,编译器无法排除它底下是个 ArrayList,而 ArrayList 的内容随时可能被别人改掉。

后果:你的 ItemList(items: List<Item>) 每次父级重组时都跟着重组,哪怕列表内容一模一样。列表长的时候,这就是掉帧。

✦ 新炉料 · 强跳过(strong skipping)已是默认

近版 Compose 编译器默认开启了强跳过模式:即使参数是不稳定的,Composable 也能跳过重组 —— 此时编译器改用实例相等(===来比较不稳定参数。

这大大缓解了上面那个问题,但没有消灭它

  • 如果你每次重组都新建一个 List(比如在 Composable 里写 items.map { ... }),实例每次都不同 → === 失败 → 照样重组
  • 而如果传的是同一个不可变列表实例,则跳过成功。

所以结论没变,只是理由更新了:状态类要不可变,且要「复用同一个实例」。强跳过救的是「你传了同一个实例但类型不稳定」的情况,救不了「你每帧都造新对象」。

三个可以立刻做的动作

// ① 用不可变集合(kotlinx.collections.immutable)
data class UiState(
    val items: PersistentList<Item> = persistentListOf()   // ✓ 编译器认它稳定
)
val next = state.copy(items = state.items.add(newItem))    // 返回新列表,旧的纹丝不动

// ② 或者用注解向编译器「背书」
@Immutable                 // 「我保证这个类的所有字段永远不变」
data class Filter(val tags: List<String>)

// ③ 别在 Composable 里现算集合
@Composable
fun Screen(state: UiState) {
    val sorted = remember(state.items) { state.items.sortedBy { it.name } }  // ✓ 记住结果
    // val sorted = state.items.sortedBy { it.name }   ✗ 每次重组都新建一个 List
}
◆ 锭 · 不可变的收益,一句话

不可变让系统敢于缓存。

Compose 敢跳过重组、diff 算法敢用引用相等做快速判断、状态能安全地在线程间共享、时间旅行调试能成立、copy() 能便宜(结构共享)—— 全都是同一条原因的不同表现:如果一个东西不会变,那么「它没变」这件事就可以被信任。

反过来,可变性的代价是怀疑:每个持有它的人都必须假设它随时会变,于是每个人都得防御性地复制、检查、加锁。

PersistentList 为什么不慢

「每次修改都返回新列表」听起来贵得可怕 —— 一万个元素的列表加一个元素,要复制一万个?

不会。kotlinx.collections.immutablePersistentList 用的是结构共享:底层是一棵树(trie),修改时只重建从根到那个叶子的路径(通常几个节点),其余部分和旧列表共用同一块内存

代价是常数因子略高(树的查找比数组索引慢一点),收益是修改从 O(n) 复制变成 O(log n) 局部重建,而且旧版本完好无损地活着(这就是「持久化 persistent」的含义 —— 和存储无关)。

对 Compose 的状态类,这是免费的午餐:你本来就要为每次状态更新造一个新的 UiState,用 PersistentList 之后,那次「造新列表」的成本从「复制整个数组」降到了「重建几个节点」,而且换来了编译器认可的稳定性。

⚠ 炉渣 · @Immutable 是一句承诺,编译器不验证

和第 4 章的 contracts 一模一样的味道:@Immutable@Stable 是你对编译器的断言,它完全相信你,一个字都不查

@Immutable
data class Filter(val tags: MutableList<String>)   // 撒谎!

// 后果:你改了 tags 的内容,Compose 不会重组 —— UI 停留在旧数据上,
// 而且没有任何报错。这类 bug 极难查,因为「UI 不更新」的嫌疑人有一百个。

规则:只在你能人肉证明「所有字段确实不可变」时才加这个注解。能用 PersistentList 就别用注解 —— 让编译器推导出稳定,永远好过你声明稳定。

顺带一提:这也是并发安全的地基

不可变对象天生线程安全 —— 没有写操作,就没有数据竞争,不需要锁。

这条在协程里尤其重要(下一炉的正题):你把一个 UiState 从 IO 线程传到主线程,如果它是不可变的,什么都不用做;如果它内部藏着一个 MutableList,你就有了一个随时可能爆炸的竞态条件,而且它只在特定时序下才复现

▸ 旧模具 · Java 侧的对应物

Java 16 的 record 相当于 Kotlin 的 data class —— 但它没有 copy()(改一个字段要手写全部构造参数),这让不可变更新在 Java 里更痛苦。

集合方面,List.of(...)(Java 9+)返回的是真正不可修改的列表(改它抛异常),比 Kotlin 的只读 List 接口更硬 —— 这一局 Java 反而更严格。但它没有结构共享(每次「修改」都要整个复制),所以它是「不可修改」,不是「持久化」。

要在 Java 里得到 PersistentList 那种东西,得引第三方库(Vavr、Guava 的 Immutable* 只做到了「不可修改」)。而 Kotlin 的 kotlinx.collections.immutable 是官方维护的。

✚ 动手锻打

1. 搜项目里的 MutableList / MutableMap 出现在 data class 字段上的地方。每一处都是一颗雷(浅拷贝共享 + Compose 不稳定 + 线程不安全,三合一)。

2. 用 Layout Inspector 的重组计数找一个重组过量的 Composable(数字明显偏高的那个)。检查它的参数:有 List 吗?有 lambda 吗?有每次都新建的对象吗?换成 PersistentList 或者用 remember 记住,再数一次 —— 这是本章最有说服力的练习,因为它给你一个能看的数字

3. 引入 kotlinx-collections-immutable,把一个 UiState 的集合字段换成 PersistentList。观察编译后 Compose 编译器的稳定性推导有没有变化(可以开 Compose Compiler 的 metrics 报告看)。

4. 检查你的 MVI reducer:state.copy(...) 出来的新状态,有没有和旧状态共享可变对象?如果有,你的「状态历史」是假的。

塑形炉结束。下一炉是本书最深的一段水域:协程。先从 suspend 编译成什么开始 —— 那台状态机是理解后面五章的唯一钥匙。