卷 III · CH 09 · 深度 09/18

Composable 是描述,不是 View 工厂

它可以按任意顺序、任意频率重跑,甚至本次结果被丢弃;所以函数体必须像一次可撤销的草稿。

RECOMPOSITIONIDENTITYSTABILITY
▷ 先答一下

一个 Composable 重组了,下面哪件事一定发生?

  1. 对应 Android View 被销毁重建
  2. 它下面每个子 Composable 都重新执行
  3. 函数重新求值,但运行时可跳过未变化的子树
  4. 所有 Modifier 都触发布局和绘制

Composition 是对调用位置的记忆

第一次执行 Composable 叫初始组合,运行时记录调用结构与状态槽位。状态变化时,读取该状态的重组作用域被标记,函数再次执行,新的描述与旧 Composition 对照后应用最小变更。它不是把 XML 换成 Kotlin,也不是每次创建一整棵 View。

状态槽位通常靠调用位置识别。条件分支改变调用顺序、列表在头部插入元素,都会改变「这里是谁」。key(id) 与 Lazy 列表的 key 给内容稳定身份,让 remember 状态、动画与副作用跟着业务对象走,而不是跟着位置走。

可以带走的判断:Compose 的身份默认来自位置;只要内容会重排,就要考虑业务 key。

重组不是性能失败

重组是正常机制,目标不是把计数器降到零。真正要问的是:重组范围是否合理,函数体是否便宜,参数不变时能否跳过。Compose 编译器根据类型稳定性判断能否安全比较参数;稳定参数相等时,可跳过子调用。

稳定不等于所有字段都用 val。一个 val items: MutableList<T> 仍可能在运行时偷偷改变而不通知 Compose。也不要随手给类型贴 @Stable;这是你向编译器作出的合同,合同撒谎会导致 UI 不更新。先使用真正不可变的 UI 模型、快照状态容器或不可变集合,再看性能证据。

读状态的阶段决定重做多少

Compose 大致经历组合、布局、绘制三阶段。在 Composable 函数体读取状态,变化会触发重组;在布局 lambda 里读,可能只重做布局;在绘制 lambda 里读,可能只重绘。把滚动偏移这种每帧变化的值延后到 graphicsLayer 或布局阶段,能绕开不必要的组合。

@Composable
fun FadingToolbar(scroll: () -> Float) {
    Box(
        Modifier.graphicsLayer {
            alpha = 1f - scroll().coerceIn(0f, 1f)
        }
    )
}

remember 缓存昂贵计算;derivedStateOf 把高频输入压成低频派生状态,例如「首项是否已离开」。两者都不是默认必加,只有计算真昂贵或输出变化频率显著更低时才值回复杂度。

三十秒回答「重组是重新执行读取了变化状态的描述函数。Compose 根据位置维护身份,根据稳定参数决定是否跳过;最终只把差异应用到底层 UI。优化先用 Layout Inspector 和基准找到热路径,再缩小状态读取范围。」
⌨ 自己跑一遍

SideEffect 只记录成功提交的重组次数。点一次按钮,父级状态变化;稳定且参数不变的子项可能被跳过:

Counter committed: 1
StaticTitle committed: 1
Counter committed: 2
StaticTitle may be skipped
@Composable
fun RecomposeProbe() {
    var count by remember { mutableIntStateOf(0) }
    SideEffect { Log.d("Probe", "Counter committed: ${count + 1}") }

    Column {
        StaticTitle(title = "Messages")
        Text("Count: $count")
        Button(onClick = { count++ }) { Text("Add") }
    }
}

@Composable
fun StaticTitle(title: String) {
    SideEffect { Log.d("Probe", "StaticTitle committed") }
    Text(title)
}

在 Android Studio 的 Empty Activity 模板运行,用 Layout Inspector 打开重组计数。Debug 日志只作学习探针;真实性能结论要在 release 构建和 Macrobenchmark 中测。

▸ 在现实里

消息列表每次收到新消息时,若没有稳定 key,插到顶部可能让大量 item 的位置身份改变,图片加载与本地展开状态跟错对象。给 key 之后,Compose 能把「同一条消息换了位置」和「出现了一条新消息」区分开。

✗ 这个直觉是错的

「重组次数越少越好,看到重组就加 remember 和 @Stable。」这会把可读性换成没有测量依据的复杂度,甚至因错误稳定合同漏更新。

重组应便宜、可跳过、无副作用。先修错误的状态范围和身份,再用工具确认瓶颈。

◇ 面试收口

答案是第 3 项。重组意味着描述函数重新求值,不等于 View 树重建,也不保证所有孩子执行。运行时能跳过输入未变且可安全比较的子树,布局与绘制也可能独立跳过。

这一章的一句话

把 Composable 写成可反复重放、没有隐藏副作用的描述,Compose 才有资格替你跳过工作。

下一章只问一个决定整个页面质量的问题:草稿、列表滚动、筛选条件和服务端消息,各自应该活过哪一种死亡?四个答案分别落在三个状态容器和一个持久层里。