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

一次完整的重组其实包含三个阶段。我个人习惯把第一个阶段叫做“逻辑树擦写”,后面两个是“物理树摆放”。

  1. Composition 阶段:Compose 会执行所有的 Composable 函数,生成一个 Slot Table(槽表)。这个阶段会做 diff 比较,决定哪些节点需要保留、更新或删除。
  2. Layout 阶段:根据 Composition 阶段生成的新节点树,计算每个元素的尺寸和位置。这里有 Modifier 的测量和放置。
  3. Draw 阶段:将 Layout 阶段后的结果渲染到 Canvas 上。Canvas 是 Android 原生的绘制 API。

你想想看,如果重组太频繁,Layout 和 Draw 会跟着反复执行。我曾见过一个列表,因为每个条目都读取了频繁变化的动画 State,导致整个列表在 16ms 帧内完成不了 Composition+Layout,结果掉帧严重。

关键点:Composition 阶段是 CPU 密集的,因为它涉及函数执行和 diff 算法。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,每次发送消息后,整个列表全量重组。CPU 从 3% 直接跳到 45%。加了个 key = { it.id } 后,CPU 回到 4%。差距就这么大。

四、实战避坑:如何避免不必要的重组?

  • 使用 remember 缓存计算结果:避免每次重组都重新计算昂贵的对象。
  • 减少 State 的作用域:把 State 尽量下放到最内层的 Composable,不要让外层的 Composable 去读取那些只在内层使用的 State。
  • 用 derivedStateOf 过滤高频变化:例如滚动偏移量,不要每次 1px 都触发重组,用 derivedStateOf 做降采样。
  • 合理使用 LazyColumn 的 key:这个说过了,不再重复。
  • 避免在 Composable 中创建 Lambda 内部类:每次重组都会创建新的 Lambda 对象,导致 diff 认为参数变了。用 remember 包住 Lambda 或提取为顶层函数。

最后,放一张我整理的重组机制流程图。希望你能把它作为日常调试的“大脑地图”。

Compose 重组机制:触发 → 三阶段 → Diff 决策 State 值变化 / 参数变化 Composition 阶段 执行 Composable 函数,生成 Slot Table Diff 算法:对比 Slot Table 检查类型、key、参数是否一致 是否可跳过? 跳过子树(不重组) 沿用旧 Slot Table 内容 标记为“需要重组” Layout 阶段 测量子节点,确定位置和尺寸 Draw 阶段(渲染到 Canvas) ⚠ 这里的 diff 是“槽位索引”对比

总结:重组的三阶段中,Composition 阶段是 diff 的主力。Diff 算法通过 Slot Table 的索引、类型、key 三者来判断是否跳过。理解这一点后,你写代码时就会本能地思考:“这个 State 的变化会影响哪些槽位?我能不能用 key 把范围缩小?”

下次遇到 CPU 飙高、UI 掉帧,别忘了先打开 Profiler 看 Composition 阶段耗时。如果那一节占用超过 10ms,八九不离十就是重组范围太大了。