重组原理深探:Compose 到底怎么“智能”重组的?

我们平时写 @Composable 函数,说白了就是在声明 UI 的状态依赖图。每次状态一变,Compose 会重新执行那些“可能受影响”的函数。但这里有个关键问题:它怎么知道哪些函数该执行、哪些该跳过?

我个人习惯把这个过程拆成三步来理解:触发 → 调度 → 执行。下面一个一个聊。

1. 触发条件:不是所有状态变化都会重组

先看一个最基础的例子:

var count by remember { mutableStateOf(0) }

当 count 的值改变时,Compose 会标记所有读取了 count 的可组合函数为“脏”。但这只是第一步——标记并不会立刻执行重组,而是等下一个帧信号到来。

为什么会这样?因为 Compose 会把多次状态变更合并到同一个帧里处理。我在项目中遇到过一个问题:连续调了三次 count++,结果 UI 只跳了两下。后来一查,是 snapshotStateObserver 做了去重,只保留最后一次。嗯,这里要注意,如果你在非 Compose 线程(比如子线程)改状态,必须通过 runOnUiThread 或者 withContext(Dispatchers.Main) 切回来,否则重组不会发生。

提示
只有 State 或 MutableState 对象的变化才能触发重组。普通 Java 类字段、ViewModel 里的 LiveData(如果不转成 State)、甚至 Flow 没收集都不行。

2. 重组作用域:只读哪片,就重排哪片

Compose 的“智能”体现在最小化作用域。看这段:

@Composable
fun Parent() {
    var text by remember { mutableStateOf("") }
    Text("标题")           // 不依赖 text
    Input(text) { text = it } // 依赖 text
}

当 text 变化时,Text("标题") 不会重新执行,因为它在当前作用域中没有读取 text。只有 Input 函数被重新调用。

但要注意:重组作用域是以“组合函数调用”为单位的。如果你把状态读取拆分到多个 lambda 里,Compose 可能会把整个父函数都标记为脏。我之前踩过一个坑——在 Box 内部直接读取了一个不可变对象,结果把整个 Box 都重组了,明明只有一小部分变了。后来我用 derivedStateOf 做了一层过滤,才把作用域打散。

3. 跳过条件:稳定性 + Key 的组合拳

Compose 会尽量跳过参数未变的重组。它依赖两个东西:

  • 稳定性(Stability)——由编译器推断,或者你用 @Stable 注解手动声明。
  • Key——显式告诉 Compose “这些参数是唯一的”,避免错误复用。

一个稳定类型(比如 String、Int、data class 且其字段全是稳定)的参数,如果值没变,Compose 就会跳过它的重组。不稳定类型(比如 List、普通类)每次都会被当作“可能变化”,导致整个子树重新执行。

警告
不要以为 data class 就一定是稳定的!如果它的某个字段是 ArrayList 或 MutableList,编译器会认为它不稳定。我曾经因为这个原因,一个列表项每次滚动都全部重绘,卡成 PPT。解决方案是:要么把列表字段换成 ImmutableList,要么用 @Stable 注解强行声明。

4. 理解 Snapshot 系统:真正的幕后黑手

Compose 的重组机制基于 Snapshot(快照)。每次读取 State 时,框架会记录当前组合函数与该状态的“关联”;每次写入时,框架会查找所有关联的函数,把它们标记为“待重组”。这个过程是并发的、无锁的,靠 原子计数 和 版本号 实现。

简单概括一下:

操作发生了什么
读 State当前组合函数被注册为该状态的观察者
写 State版本号 +1,通知所有观察者(组合函数)“你被标记为脏了”
帧信号到来遍历脏函数集合,逐个重组;期间如果状态又变,会再次标记

这里有个容易忽略的点:重组是“幂等”的。同一个函数即使被多次标记,最终只会执行一次(在当前帧)。这也是为什么连续改状态不会导致多次重组的原因。

5. 一个必知的“坑”:重组内修改状态

在重组过程中,如果你在重组函数里又修改了同一个状态(比如循环增加),会造成无穷重组。Compose 会检测到并抛出异常:Recompose error: produced more than 100 patches in a single recompose。我曾经写过一个带轮播效果的组件,忘记在 LaunchedEffect 里更新索引,直接在组合函数里用 delay 循环,结果崩了。所以记住:状态的修改必须在副作用(SideEffect、LaunchedEffect)里做,不要在重组体里直接写。

Compose 重组触发与决策流程 状态值变化(写操作) Snapshot 版本号 +1 调度:标记对应作用域为脏 参数稳定性判断(@Stable / data class) 不稳定 → 执行重组 稳定 → 检查 Key Key 相同 → 跳过重组 Key 不同 → 重组执行

6. 小结:重组决策的关键因素

我把这些要点整理成一个速查表,方便你对照:

条件是否触发重组常见踩坑
读取了 State 且值变化是忘了收集 Flow?用 collectAsState() 才行
读取了 State 但值没变否(状态引用未变)可变对象内属性变了但引用没变——不会触发!
参数是稳定类型且值没变跳过data class 内部有不可变集合才稳定
参数是不稳定类型每次重组用 @Stable 或转换为不可变对象
Key 与上次相同跳过(常用于列表 item)Key 必须是唯一且稳定的,别用随机数
记住一句话:
重组不是“从头到尾重新画”,而是“只执行那些真的可能变了的函数”。你理解了这个粒度,后面学到 Mock 回放方法时,才能知道哪些地方需要打桩、哪些地方可以直接跳过。