4. Compose 编译器与运行时机制

Compose 的性能表现并不主要取决于“写得多不多”,而取决于“编译器如何理解你的状态读取与函数调用”。Compose 编译器在编译期把可组合函数改写成带有 Composer 参数的形式,并在运行时通过 Composer 记录每个重组作用域的状态依赖。理解这条链路,才能解释为什么同样的界面代码,有的只刷新一个文本,有的却整页重算。

一、重组作用域:编译器插入的“刷新边界”

重组作用域(Recomposition Scope)是编译器为可组合函数中“可能独立失效”的代码块建立的刷新单元。最典型的是内联 lambda:当你在 Column、Row、Box 的 content 中写代码,编译器会把它包装成一个可重启的 lambda,并为其分配一个独立的 RecomposeScope。当该 lambda 内读取的 State 发生变化时,只有这个 lambda 被标记为失效,而不是整个外层函数。

关键点在于:作用域由编译器按调用点划分,而不是按代码行划分。同一个函数被调用两次,会得到两个不同的作用域实例;循环中调用同一个可组合函数,每次迭代也会形成独立作用域。因此,把状态读取尽量下沉到小的 lambda 或子可组合函数中,是缩小刷新范围最直接的手段。

二、智能重组:只重跑“读过该状态”的作用域

智能重组(Smart Recomposition)指运行时只重新执行那些真正读取了已变更状态的作用域。Compose 运行时维护一张“状态对象 → 作用域”的依赖表:作用域首次执行时,每次读取 State.value 都会把当前作用域登记为该状态的观察者;状态写入时,只把登记过的作用域标记为失效,并在下一帧统一调度执行。

这带来两个工程结论:其一,读取位置决定刷新范围,把 state.value 读在父函数里,父函数就会整体失效;其二,写入时机影响帧节奏,同一帧内多次写入会被合并,但跨帧写入会触发多次重组。下面这张图展示了从状态写入到作用域重跑、再到跳过判断的完整信号链。

状态变更 → 作用域失效 → 跳过判断 → 重组执行 State 写入 count.value = 3 记录 Snapshot 变更 依赖表查询 State → 观察作用域集合 仅标记登记过的 Scope 作用域失效 invalidate(scope) 等待下一帧调度 帧调度 合并同帧失效 Recomposer 执行 跳过优化判断(Skip Optimization) ① 所有参数 equals 相等? ② 参数类型是否被推断为 Stable? ③ 函数是否被标记 @NonRestartableComposable? 跳过执行 复用上次 UI 结果 零重组开销 全部满足 重新执行 重跑 lambda 体 重新登记依赖 任一条件不满足 稳定性推断(Stability Inference) • val 基本类型 / String → Stable • data class 全字段 Stable → Stable • var 字段 / List / Set → Unstable • @Stable / @Immutable 可手动标注 影响条件②

三、跳过优化:为什么“没变”的函数可以完全不跑

跳过优化是 Compose 性能的核心红利。当某个可组合函数被标记为失效、即将重新执行前,运行时会先做一次判断:如果该函数的所有参数与上次执行时相等,且参数类型都被推断为稳定,那么这次执行可以直接跳过,复用上一次生成的 UI 节点。跳过意味着不进入函数体、不重新读取状态、不产生新的节点操作。

跳过的前提是“参数可比”。Compose 对参数使用 equals 比较,因此 data class 天然友好,而普通类若未重写 equals,每次新建实例都会被视为“不相等”,导致跳过失败。这也是为什么在列表项中传入 data class 比传入可变对象更高效。

四、稳定性推断:编译器如何判断一个类型能不能跳过

稳定性(Stability)是编译器对类型的静态推断结果,含义是:该类型的公开属性若发生变化,Compose 能通过 equals 感知到。推断规则大致如下:

  • val 修饰的基本类型、String、函数类型:Stable。
  • data class 且所有字段均为 Stable:Stable。
  • 含 var 字段、或字段类型为 List/Set/Map 等接口类型:Unstable(接口实现可能可变)。
  • 使用 @Stable 或 @Immutable 可手动声明,编译器会信任该声明。

当参数类型被推断为 Unstable 时,即使值没变,编译器也无法保证 equals 可靠,于是该函数无法被跳过,每次父作用域失效都会连带重跑。这是很多“看起来只改了一个字段却整页刷新”问题的根因。

五、性能影响与工程实践

把上述机制落到工程上,可以形成几条可操作的判断依据:

现象可能原因处理方式
只改一个文本,整页重组状态读取位置过高把 state.value 下沉到最小 lambda
列表滚动卡顿item 参数为 Unstable 类型改用 data class 或加 @Immutable
函数每次都被重跑参数未重写 equals使用 data class 或实现 equals
跳过失效传入了 List 接口类型改用 ImmutableList 或 @Stable 包装
可以用编译器指标快速定位问题:在 build.gradle.kts 中开启 composeCompiler { metricsDestination = layout.buildDirectory.dir("compose-metrics") },构建后查看生成的 *-composables.txt 与 *-classes.txt。前者会标注每个可组合函数是否 restartable、是否 skippable,后者会列出被推断为 unstable 的类。若某个函数显示 skippable=false,就应检查其参数类型是否稳定。
不要为了“看起来稳定”而滥用 @Stable。该注解是承诺:若类型内部存在可变状态却标注 Stable,Compose 会跳过本应发生的重组,导致界面不更新。稳定性标注必须与真实的可变性语义一致。

总结来看,Compose 的性能模型是一条清晰的因果链:状态写入触发依赖表查询,只失效登记过的作用域;作用域重跑前先做跳过判断,判断依赖参数相等性与类型稳定性;稳定性由编译器静态推断,也可由开发者显式声明。把状态读取下沉、把参数类型做稳、把不稳定类型收敛到最小范围,就能让智能重组真正发挥作用,而不是退化成整页重算。