Compose 为什么会频繁重组?从 Stability 到状态读取位置

Compose 频繁重组通常不是“Compose 慢”,而是状态读取位置、参数稳定性和对象创建方式让运行时无法跳过不必要的调用。

理解重组时先记住一句话:Compose 会在状态变化后重新执行读取该状态的 Composable,但它会尽量跳过参数没有变化且稳定的子树。问题往往出在“变化范围太大”和“无法判断是否稳定”。

状态读取位置决定重组范围

最常见的问题是父级读取了变化频繁的状态,然后把结果层层传给子组件。比如列表滚动状态、输入框文本、倒计时、播放进度,如果在页面根节点读取,整个页面都会进入重组候选范围。

更好的做法是把状态读取下沉到真正需要它的地方。只有按钮需要 loading,就不要让整个 screen 读取 loading;只有列表头需要滚动偏移,就不要让每个 item 都感知滚动状态。

这也是 Compose 性能优化里最重要的思路:不是消灭重组,而是缩小重组影响范围。

Stability 为什么重要

Compose 运行时需要判断一个 Composable 能否跳过。如果参数类型稳定,且新旧参数相等,它就可以跳过;如果参数不稳定,运行时会更保守。

常见不稳定来源包括:

  • 普通可变类,没有明确不可变语义。
  • MutableListMutableMap 这类可变集合。
  • 每次重组都创建的新 lambda 或新对象。
  • 来自 Java 或未标注稳定性的第三方模型。

不要滥用 @Stable@Immutable。它们不是性能魔法,而是你向 Compose 做出的契约:这个类型的变化是可追踪或不可变的。如果实际对象会在外部悄悄变化,标注只会让 UI 更难排查。

列表是重组问题的放大器

LazyColumn 里如果 item 没有稳定 key,数据插入、删除、排序时,Compose 很难准确复用已有 item 状态。表现可能是重组增多、滚动状态错乱、图片重新加载、输入框状态跳动。

列表 item 还要避免在 items block 里临时创建复杂对象。例如每次重组都 map 一份列表、创建 formatter、拼装大对象,都会让 item 参数看起来一直在变化。

建议做法是:

  • items 提供稳定 key。
  • UI model 在 ViewModel 层准备好,Composable 只负责展示。
  • 大对象用 remember 缓存,key 要准确。
  • 频繁变化的状态只传给真正需要的 item。

derivedStateOf 不是万能药

derivedStateOf 适合把高频变化压缩成低频结果。例如滚动 index 每帧都变,但“是否显示返回顶部按钮”只在 false/true 边界变化,这时用它有意义。

如果只是把普通字符串拼接、简单字段读取包进 derivedStateOf,反而增加复杂度。更糟的是在错误位置创建 derivedStateOf,每次重组都重新创建,既没有缓存效果,也让代码更难读。

判断标准很简单:输入变化很多次,输出只变化少数几次,才值得考虑 derivedStateOf

怎么确认是不是重组问题

不要只靠肉眼感觉。Android Studio 的 Compose Layout Inspector 可以看重组次数,Perfetto/系统 trace 可以看主线程是否被 Compose runtime、measure/layout/draw 占满。对于疑似热点组件,可以临时加日志或使用 Compose compiler metrics 看稳定性推断。

如果页面卡顿但重组次数不高,问题可能在布局测量、图片解码、主线程 I/O 或 RenderThread;如果重组次数很高但每次很轻,也未必是性能瓶颈。重组是信号,不是结论。

工程上的治理方式

Compose 项目要建立几个基本约束:UI state 尽量不可变;列表必须有 key;页面根节点不读取高频状态;复杂计算移到 ViewModel 或 remember;公共组件不要接收巨大的可变对象;性能敏感页面保留重组观测手段。

频繁重组不是单点 bug,而是状态设计和组件边界的问题。把状态放对位置,比在每个 Composable 上补 remember 更有效。

深入阅读