Compose 重组机制:触发条件、三阶段与 Diff 算法
重组(Recomposition)是 Compose 最核心的运行时机制。说白了,它就是 Compose 检测到状态变化后,自动重新执行那些依赖该状态的 @Composable 函数,并更新 UI 的过程。我在很多项目中看到朋友把重组当成“黑魔法”,其实它的原理一点也不玄——就是一套高效的增量更新策略。
如果理解不好重组,你的应用很可能出现两类问题:要么 UI 不更新,要么 CPU 被拖垮。今天我们就把它彻底讲透。
一、重组的触发条件
重组不会无缘无故发生。Compsoe 只会在以下三种情况下发起重组:
- State 对象的 value 发生变化:比如
mutableStateOf()、StateFlow、LiveData的值被重新赋值。 - State 的读取者(Reader)被调用:只有在 Composition 期间被读取的 State,才会建立依赖关系。如果 State 只在 lambda 或副作用里读取,不会触发重组。
- Input 参数显式变化:Composable 函数的参数通过
key或者 equals 检测是否改变。
remember { mutableStateListOf() } 装数据,结果每次更新时整个列表都重绘。后来才发现,mutableStateListOf 的更新触发的是整个 List 的替换,而不是条目级别的。改成 SnapshotStateList 才解决。
这里要特别强调:重组只影响那些在 Composition 中真正读取了变化 State 的 Composable。你不读,它就不刷。这就是 Compose 的“智能跳过”机制。
| 触发源 | 例子 | 重组范围 |
|---|---|---|
| State.value = 新值 | var count by remember { mutableStateOf(0) } |
所有读取 count 的 Composable |
| StateFlow 发射新值 | viewModel.uiState.collectAsState() |
读取该 State 的 Composable |
| 参数类型变化 | @Composable fun MyText(text: String) |
只有该调用点的 MyText |
二、重组三阶段:Composition → Layout → Draw
一次完整的重组其实包含三个阶段。我个人习惯把第一个阶段叫做“逻辑树擦写”,后面两个是“物理树摆放”。
- Composition 阶段:Compose 会执行所有的 Composable 函数,生成一个 Slot Table(槽表)。这个阶段会做 diff 比较,决定哪些节点需要保留、更新或删除。
- Layout 阶段:根据 Composition 阶段生成的新节点树,计算每个元素的尺寸和位置。这里有
Modifier的测量和放置。 - Draw 阶段:将 Layout 阶段后的结果渲染到 Canvas 上。Canvas 是 Android 原生的绘制 API。
你想想看,如果重组太频繁,Layout 和 Draw 会跟着反复执行。我曾见过一个列表,因为每个条目都读取了频繁变化的动画 State,导致整个列表在 16ms 帧内完成不了 Composition+Layout,结果掉帧严重。
IntrinsicSize 或自定义 Layout 也会变重。Draw 阶段通常是 GPU 负担。
我个人习惯在开发时打开 Compose 的布局边界检查工具(Layout Inspector),用蓝、红、绿三个颜色直观看到每个阶段的时间占比。对定位重组抖动非常有帮助。
三、Diff 算法:Compose 是怎么知道该更新什么的?
Compose 的 diff 算法核心思想是“位置感知的位置化建模”。它不靠传统的 Virtual DOM 那种递归 diff,而是利用 Slot Table 的哈希索引 + 类型对比。
简单说:每个 Composable 函数在 Slot Table 里有一个唯一的“槽位”(slot index)。Compose 在执行时对比新的 Composable 树和旧树在相同槽位的函数签名、key、参数。如果完全一致,直接跳过整个子树(这就是“跳过重组”的优化)。
如果不同,就标记为“需要重组”,然后进入该 Composable 内部继续 diff。这个过程是广度优先加深度优先的混合。
// 背后的大致逻辑(伪代码)
fun diffCompose(oldSlots: SlotTable, newSlots: SlotTable) {
for (i in oldSlots.indices) {
val oldSlot = oldSlots[i]
val newSlot = newSlots[i]
if (oldSlot.type != newSlot.type) {
markForRecompose(i) // 整个替换
} else if (oldSlot.key != newSlot.key || oldSlot.params != newSlot.params) {
markForRecompose(i) // 需要更新
} else {
skipChildren(i) // 子树沿用旧树,不执行
}
}
}
这就是为什么你在写 LazyColumn 时必须提供 key。没有 key 时,Compose 只能根据 index 来 diff,如果列表插入或删除一个元素,后面的所有条目都会触发重组。有了 key,Compose 可以精确地移动、插入、删除各个节点。
key = { it.id } 后,CPU 回到 4%。差距就这么大。
四、实战避坑:如何避免不必要的重组?
- 使用
remember缓存计算结果:避免每次重组都重新计算昂贵的对象。 - 减少 State 的作用域:把 State 尽量下放到最内层的 Composable,不要让外层的 Composable 去读取那些只在内层使用的 State。
- 用
derivedStateOf过滤高频变化:例如滚动偏移量,不要每次 1px 都触发重组,用derivedStateOf做降采样。 - 合理使用
LazyColumn的 key:这个说过了,不再重复。 - 避免在 Composable 中创建 Lambda 内部类:每次重组都会创建新的 Lambda 对象,导致 diff 认为参数变了。用
remember包住 Lambda 或提取为顶层函数。
最后,放一张我整理的重组机制流程图。希望你能把它作为日常调试的“大脑地图”。
总结:重组的三阶段中,Composition 阶段是 diff 的主力。Diff 算法通过 Slot Table 的索引、类型、key 三者来判断是否跳过。理解这一点后,你写代码时就会本能地思考:“这个 State 的变化会影响哪些槽位?我能不能用 key 把范围缩小?”
下次遇到 CPU 飙高、UI 掉帧,别忘了先打开 Profiler 看 Composition 阶段耗时。如果那一节占用超过 10ms,八九不离十就是重组范围太大了。