什么是重组(Recomposition):基础概念与触发条件

重组,说白了就是 Compose 在状态变化后重新执行你的 Composable 函数,生成新的 UI 片段。你不需要手动写 setText()、notifyDataSetChanged() 那一套,把状态交给 Compose,它自动盯着变化,变了就重跑。

我刚从 View 系统转到 Compose 时,总觉得这玩意儿是个黑盒。后来跟踪了几次重组日志才明白——它其实是一个有范围的“局部刷新”。不是整个界面推倒重来,只更新变了的那部分。

重组到底干了什么?

每个 Composable 函数本质是一个代码块。Compose 会记住它的输入参数、读取到的状态,以及当前的执行位置。当某个状态变量变化时,Compose 找到“读过这个状态”的 Composable 作用域,重新调用它们。

  • 只重跑读取了变化状态的代码块,不是整个 UI 树
  • 不是 View 的重新布局或重绘,只是函数的重新执行
  • 执行结果生成新的 LayoutNode 树,然后 Compose 对比差异,高效更新
个人经验: 我刚开始总把重组和传统 setText() 混淆。其实 Compose 重组跑的是函数里的 @Composable 代码,它可能生成新的 Modifier、新的 Text 参数,但 Text 本身可能只有文字变了,其他属性不变。Compose 会智能跳过不必要的布局和绘制。

触发重组的三个核心条件

不是所有代码变化都会触发重组。只有 Compose 能够“观察”到的状态变化才行。我整理了一个常见触发条件表:

触发来源 具体场景 是否可跳过
MutableState 变化 mutableStateOf、remember { mutableStateOf } 的 .value 被重新赋值 可跳过,前提是读取该状态的 Composable 参数没变
StateFlow/LiveData 收集 collectAsState()、observeAsState() 在协程/生命周期中发射新值 可跳过,框架会自动处理
输入事件回调 用户点击、滑动、键盘事件导致状态变更 不会跳过,必须触发重组
系统配置变化 屏幕旋转、深色模式切换、字体缩放 会触发整个 Activity 级别的重组
Composable 参数变化 父 Composable 传入的参数值发生改变 可跳过,使用 key 或 derivedStateOf 优化
容易踩坑的地方: 普通的 Kotlin 变量(var a = 0)变了,Compose 是不知道的!必须用 mutableStateOf 包裹,或者用 remember 把它变成可观察状态。我曾经看到一个同事直接在 Composable 里写了 var count = 0,然后点了半天按钮 UI 纹丝不动……嗯,这种坑一次就够了。

重组作用域:不是所有 Composable 都重跑

Compose 以“重组作用域”(Recompose Scope)为单位来执行。每个 Composable 函数内的花括号都可能被拆成多个作用域,取决于 Compose 编译插件的优化。

  • 一个 Composable 函数可以包含 多个作用域
  • 只有读取了变化状态的那个作用域会被重新执行
  • 兄弟作用域、父作用域如果没读这个状态,就不会被重跑

举个例子:

@Composable
fun MyScreen() {
    var text by remember { mutableStateOf("Hello") }
    // 作用域 A:读取 text
    Text(text = text)

    // 作用域 B:没读 text
    Button(onClick = { text = "World" }) {
        Text("Change")
    }
}

这里 Button 所在的 Composable 不会因为 text 变化而重跑,只有 Text 那部分会重跑。这就是 Compose 高效的秘诀。

注意: 如果你直接在 lambda 里读取了状态,比如 Button(onClick = { println(text) }),那这个 Button 的 onClick lambda 会在重组时被重新捕获新的 text 值,但 Button 本身可能不重跑。这是很多抖动问题的源头——你以为没重跑,可 lambda 内部的状态却变了。

重组的幕后推手:Snapshot 系统

Compose 底层靠 Snapshot(快照) 来记录状态读写。每次状态变化都会产生一个新版本号,Compose 收集所有“读过该状态”的记录,然后批量调度重组。

  • 读操作:把当前 Composable 作用域注册到状态的观察者列表
  • 写操作:标记所有注册过该状态的 Compose 作用域为“需要重组”
  • 调度:在下一帧动画或布局前,以最小粒度执行重组

我把这个过程画成了流程图,方便你理解:

重组触发流程 Composable 执行 读取 MutableState 作用域注册到 Snapshot 状态变化? 无重组 标记作用域重组 重新执行 Composable 只有读取过变化状态的作用域会重跑,其他作用域和父节点都不会被影响

重组 vs 布局 vs 绘制:别再搞混了

很多新手把重组和布局混为一谈。我直接列个对比表:

阶段 做什么 是否频繁 性能影响
重组 执行 Composable 函数,生成 UI 描述 可能一帧多次 轻量,但函数体内有耗时操作会卡
布局 计算宽高和位置 一帧一次 中等,复杂布局会耗时
绘制 将布局结果渲染到屏幕 一帧一次 重,涉及 GPU 操作

你想想看:如果重组阶段做了大量计算,比如读取数据库、解析 JSON,那即使后面布局和绘制没变,整帧也会被拖慢。这就是重组抖动最常见的根因。

避坑指南: 我曾经在某个 Composable 里直接调用了 Room 数据库查询,结果每次状态变化都跑一次查询,页面卡得像幻灯片。后来把查询移到了 ViewModel 里用 Flow 收集,就好了。重组函数里不该有副作用,只负责组合 UI 描述。

小结:记住这三句话

  1. 重组是响应式的——你只管声明 UI 和状态,Compose 自己知道什么时候更新。
  2. 重组是有范围的——不是全屏重绘,只有读了变化状态的那段代码重跑。
  3. 重组不是免费午餐——如果函数体里有昂贵操作,该优化还是要优化。

这一章把最基础的概念讲清楚了。后面我们会深入那些让你头疼的“后台状态变更导致重组抖动”问题——比如协程里改了 state、在非 Compose 线程改状态、还有那些莫名其妙的多余重组。到时候咱们再具体分析。