一、重组的本质
Compose 把界面描述成一个函数。状态变化时,框架不会重建整棵视图树,而是重新执行读取了该状态的 Composable 函数,并对比新旧的 UI 结构决定要更新哪些渲染节点。这个过程就叫重组(recomposition)。
理解重组的关键是三个概念:
- 作用域(scope):每个
@Composable函数调用点都是一个可独立重组的范围。 - 跳过(skip):如果一次重组中,某作用域的所有参数都没变,框架可以跳过它的执行。
- 失效(invalidate):当
State的value被写入,所有在组合期间读取过它的作用域被标记为失效,等待下一帧重组。
因此"优化重组"的本质只有两条路:让不该执行的作用域被跳过,或者让失效的作用域尽量小。前者靠稳定性,后者靠状态下沉。
| 概念 | 含义 | 优化手段 |
|---|---|---|
| 作用域 | 一次 Composable 调用 | 拆分函数、状态下沉 |
| 跳过 | 参数未变则不执行 | 稳定性、key |
| 失效 | 读取过的 State 被写入 | derivedStateOf、snapshotFlow |
| 帧调度 | 失效在一帧内合并执行 | 减少无效写入 |
与 View 体系对比:View 的 invalidate() 触发的是重绘(draw),而重组是重新执行逻辑,可能级联触发子作用域。二者的成本模型完全不同,这也是为什么 Compose 的优化点在"少执行"而非"少绘制"。
一句话总结:重组优化的全部工作,就是让"该跳过的作用域能跳过,该小的失效范围足够小"。
二、重组触发条件
一次重组只会由以下几类事件发起:
State的值发生写入,且新值不等于旧值(State使用equals做结构相等判断)。- 父作用域重组,且本作用域不满足跳过条件(参数变化或不可跳过)。
CompositionLocal提供的值变化,所有读取它的作用域失效。- 外部强制刷新,如
Recomposer收到invalidate或Snapshot应用。
注意第 1 条中的"新值不等于旧值":mutableStateOf 默认使用 structuralEqualityPolicy(),写入相同值不会触发重组。这正是"写入前先判等"能省下重组的原因。如果需要强制每次写入都触发,可用 mutableStateOf(value, referentialEqualityPolicy())。
var count by remember { mutableStateOf(0) }
onClick = { count = count } // 写入相同值,无效果
val forced = remember { mutableStateOf(0, referentialEqualityPolicy()) }
另一个常被忽略的触发源是读取位置。重组只发生在"读取"发生的作用域,把 state.value 的读取从父 Composable 移到子 Composable,失效范围就缩小了:父级只做 Child(scrollState = scroll) 的转发,由 Child 内部读取 scrollState.value,滚动时便只有 Child 重组。
三、稳定性与 @Stable @Immutable
Compose 编译器在编译期会为每个类型推断稳定性(stability):一个类型稳定,意味着它的 equals 结果在任意时刻都可信,且其公开属性变化时能被组合感知。
判定规则可以简化成三条:
- 所有公开属性都是
val,且类型本身稳定。 - 基本类型(
Int、Boolean、String等)与函数类型默认稳定。 var属性或类型参数(泛型)会使类型被判定为不稳定。
对于不稳定的参数类型,编译器无法保证"参数没变",因此该 Composable 不可跳过,父级每次重组都会带着它一起执行。
// 不稳定:含 var 属性
data class User(var name: String, var age: Int)
// 稳定:全部 val,且属性类型稳定
data class User(val name: String, val age: Int)
当编译器推断不出稳定性,但开发者确信它稳定时,可以用两个注解显式声明:
| 注解 | 语义 | 适用场景 |
|---|---|---|
@Immutable | 值永不改变 | 不可变数据类 |
@Stable | 值可变但变化可被组合感知 | 实现了 State 的观察者对象 |
二者的区别很关键:@Immutable 承诺"内容不会变",@Stable 承诺"变了我会通知你"。把可变对象标成 @Immutable 会导致界面不更新,这是最危险的误用。
@Immutable
data class UiState(val items: List<Item>, val loading: Boolean)
@Stable
class CounterState {
var count by mutableStateOf(0)
}
@Stable 要求所有公开属性要么是稳定的 val,要么是 MutableState 支撑的 var。上例中 count 由 mutableStateOf 支撑,因此满足"变化可被感知"。
一句话总结:稳定性是编译器对"参数是否可能变化"的静态推断,注解是开发者对推断结果的覆盖——用错了比不用更糟。
四、List 与 data class 的稳定性陷阱
List<T> 是 Compose 中最典型的稳定性陷阱。List 是接口,编译期无法知道运行时是 ArrayList 还是不可变实现,因此 List 被判定为不稳定,除非泛型参数 T 本身稳定。
data class Item(val id: Long, val title: String) // 稳定
@Composable
fun ListScreen(items: List<Item>) { // 参数 List<Item> 仍不稳定
// ...
}
List<Item> 中的 Item 稳定,但 List 接口本身不稳定,所以 ListScreen 不可跳过。三种解法:
| 方案 | 写法 | 权衡 |
|---|---|---|
| 用不可变集合 | kotlinx.collections.immutable 的 ImmutableList | 需引入依赖,集合操作需改写 |
| 标记包装类型 | @Immutable data class ItemList(val items: List<Item>) | 零依赖,但每次都要包一层 |
传递 State<List<T>> | 参数改为 State<List<Item>> | 适用于列表频繁变化且需延迟读取的场景 |
kotlinx-collections-immutable 的 persistentListOf 会被 Compose 编译器识别为稳定类型,是官方推荐做法:
import kotlinx.collections.immutable.ImmutableList
@Composable
fun ListScreen(items: ImmutableList<Item>) { // 稳定,可跳过
LazyColumn { items(items, key = { it.id }) { ItemRow(it) } }
}
另一个高频陷阱是默认参数与 Modifier。Modifier 本身是稳定的,但如果参数列表里混入了 lambda 或 List,整个函数仍不可跳过。使用 Compose 编译器指标(compiler metrics)可以看到哪些函数被标记为 unstable:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
生成的 *-composables.txt 与 *-classes.txt 会逐函数列出 restartable、skippable 与每个参数的稳定性,是排查不稳定类型最直接的手段。
五、key 与 movableContentOf
key 的作用是给组合中的一段内容一个身份标识。当同一位置的内容在不同分支间切换时,没有 key 会被视为"同一个节点被改写",remember 状态会被保留;有 key 则被视为"旧节点销毁、新节点创建"。
// 没有 key:切换 tab 时 remember 的状态被错误复用
if (selectedTab == 0) TabA() else TabB()
// 有 key:每个 tab 拥有独立状态
key(selectedTab) {
if (selectedTab == 0) TabA() else TabB()
}
key 在 LazyColumn 中同样承担 item 身份的作用,这一点与 Jetpack Compose 声明式 UI 开发
中的列表章节相互印证。
movableContentOf 解决的是另一类问题:把一段组合内容在树的不同位置之间搬运,同时保留其内部状态。典型场景是"从列表视图切到全屏视图,同一个播放器实例不能重建"。
val playerContent = remember { movableContentOf { PlayerControls(controller) } }
if (isFullScreen) FullScreenLayout { playerContent() }
else InlineLayout { playerContent() }
movableContentOf 与 key 的区别:key 只改变身份标识,内容仍在原位置;movableContentOf 允许内容整体移动到树的另一处,remember 与 DisposableEffect 状态随之迁移,不会重新初始化。
| 手段 | 作用 | 状态是否保留 |
|---|---|---|
key(x) | 改变组合身份 | 不同 key 之间不保留 |
movableContentOf | 跨位置搬运内容 | 保留并迁移 |
remember(key) | 重新计算值 | 按 key 重置 |
六、derivedStateOf 与 snapshotFlow
derivedStateOf 用于把一个或多个 State 映射成新的 State,并且只在结果真正变化时通知下游。它的价值在于把"高频输入"压缩成"低频输出"。
val listState = rememberLazyListState()
// 错误:滚动时每一像素都触发重组
val showButtonA = listState.firstVisibleItemIndex > 0
// 正确:只在布尔值翻转时触发
val showButtonB by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
判断是否需要 derivedStateOf 的标准是:输入变化频率远高于输出变化频率。如果输入输出一对一变化,直接用表达式更省,因为 derivedStateOf 自身要维护依赖追踪结构。
snapshotFlow 则把 Compose 的 Snapshot 系统转换成 Flow,用于把状态变化接到协程侧:
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.filter { it > 20 }
.collect { index -> analytics.logScroll(index) }
}
snapshotFlow 的关键特性是:它在块内读取的 State 会被自动追踪,值变化时重新执行并比较结果,只有结果变化才向下游发射。因此它天然带有 distinctUntilChanged 的效果(但仍建议显式声明以表达意图)。
| API | 输出 | 主要用途 |
|---|---|---|
derivedStateOf | State<T> | 降低重组频率 |
snapshotFlow | Flow<T> | 桥接到协程、副作用 |
rememberUpdatedState | State<T> | 长生命周期 lambda 读取最新值 |
rememberUpdatedState 常与 LaunchedEffect 配合,避免把变化的回调写进 LaunchedEffect 的 key 而导致副作用重启:
@Composable
fun Timer(onTick: () -> Unit) {
val currentOnTick by rememberUpdatedState(onTick)
LaunchedEffect(Unit) {
while (true) {
delay(1000)
currentOnTick() // 始终调用最新的回调
}
}
}
七、remember 与 lambda 稳定性
lambda 在 Kotlin 中会被编译成对象。如果每次重组都新建一个 lambda 实例,它的引用就变了,导致接收它的 Composable 无法跳过。
Button(onClick = { viewModel.submit() }) { Text("提交") } // 每次重组新建 lambda
val onSubmit = remember { { viewModel.submit() } } // 用 remember 固定引用
Button(onClick = onSubmit) { Text("提交") }
不过,从 Kotlin 2.0 的 Compose 编译器开始,lambda 的稳定性由 strong skipping mode 自动处理(见第十一节),这类手动 remember 包装在多数情况下已不再必要。但它仍有价值:当 lambda 捕获了变化的值时,remember 会导致捕获旧值,因此正确的写法是 remember(value) { { use(value) } },而这又会因 value 变化而重建 lambda——所以实践中更推荐用 rememberUpdatedState。
一个必须掌握的例外是副作用 lambda。DisposableEffect、LaunchedEffect 的 key 决定何时重启;把变化的 lambda 放进 key 会导致频繁重启:
DisposableEffect(onChange) { onDispose { } } // 错误:副作用随 lambda 重启
DisposableEffect(Unit) { onDispose { } } // 正确:只依赖需要重启的 key
八、CompositionLocal 的性能代价
CompositionLocal 提供隐式数据传递,但它有明确的性能成本:
compositionLocalOf:读取被追踪,值变化时只有读取者重组,但每次读取都有开销。staticCompositionLocalOf:读取开销最低,但值变化会导致整个子树重组。
因此判断标准是"变化频率":几乎不变的主题、密度、语言用 staticCompositionLocalOf;会变化的数据用 compositionLocalOf,且应尽量缩小其 Provider 的作用范围。
CompositionLocalProvider(LocalUser provides user) { UserAvatar() } // Provider 只包裹需要的子树
另一个隐性成本是过度读取。如果一个 Composable 只用到 LocalUser 中的 name,却读取了整个 LocalUser,那么 LocalUser 的其他字段变化也会让它重组。拆分成更细粒度的 LocalUserName 可以避免。
九、Recomposer 与帧调度
Recomposer 是 Compose 运行时的调度中枢。它监听 Snapshot 的应用事件,把失效的作用域收集起来,并在下一帧(Choreographer 的 doFrame 回调)统一执行重组。
这带来一个重要的实践结论:同一帧内的多次状态写入会被合并。因此把循环里的十次写入放在同一帧内,只会触发一次重组。
repeat(10) { list.add(it) } // 同一帧内写入,合并为一次重组
Snapshot.withMutableSnapshot {
a.value = 1
b.value = 2 // 两次写入作为一次原子提交应用
}
Recomposer 还会处理重组过程中的再次失效:如果重组时又写入了状态,该作用域会被重新加入队列,在同一帧内继续处理,直到收敛或达到帧预算上限。超过预算会让这一帧"跳帧",表现为掉帧。
因此,在 Composable 执行期间写入状态是危险模式:
@Composable
fun Bad() {
var x by remember { mutableStateOf(0) }
x = 1 // 重组中写入,可能触发无限重组
}
这类写入应放进 SideEffect、LaunchedEffect 或事件回调中。
十、用工具定位重组
凭直觉猜测重组来源几乎总是错的。Compose 提供了两套官方工具:
Layout Inspector(Android Studio):运行 App 后打开 Layout Inspector,勾选 “Show Recomposition Counts”,可以看到每个 Composable 的重组次数与跳过次数。重组次数远高于预期的节点就是优化目标。
Composition Tracing:在代码中插入 Modifier.composed 或使用 androidx.compose.runtime.tracing 的 trace("tag") 包裹可疑区域,配合系统 Perfetto 抓取 trace,可以精确看到重组发生在哪一帧、耗时多少。
import androidx.compose.runtime.tracing.trace
@Composable
fun ExpensiveList() {
trace("ExpensiveList") {
// 这段区域的重组会在 Perfetto 中显示为独立切片
}
}
三个可量化的排查指标:
| 指标 | 含义 | 健康值 |
|---|---|---|
| Recomposition Count | 该节点重组次数 | 与状态变化次数同量级 |
| Skipped Count | 被跳过的次数 | 越高越好 |
| Recomposition Highlighter | 闪烁显示重组范围 | 闪烁区域应尽量小 |
Composition Tracing 还可以配合 Macrobenchmark 的 TraceSectionMetric 做回归测试,把"某页面重组次数超过阈值"变成 CI 可拦截的失败。
十一、Strong Skipping Mode
Kotlin 2.0.20 起,Compose 编译器默认启用 strong skipping mode(在 2.0.20 中为默认开启,1.5.x 需手动开启)。它改变了跳过规则的判定方式:
- 不稳定参数按引用相等比较。以前不稳定参数一律导致不可跳过,现在只要引用相同即可跳过。
- lambda 被视为稳定。lambda 不再因为"每次新建实例"而破坏跳过,编译器会为 lambda 生成记忆化包装。
这带来的直接收益是:List<T>、含 var 的类、未记忆化的 lambda 都不再自动破坏跳过。但要注意语义变化:如果某个不稳定对象被原地修改(mutate)后重新传入,引用相等会让它被跳过,界面不更新。这类"原地修改"必须改为创建新对象:
items.add(newItem) // 危险:原地修改,可能不更新
state = state.copy(items = items)
state = state.copy(items = state.items + newItem) // 安全:创建新实例
strong skipping 不是"关闭稳定性检查",而是把"参数是否相等"的判断从结构相等降级为引用相等。因此它降低了不稳定的惩罚,但没有消除不稳定的语义风险。
| 版本 | strong skipping 默认状态 | 备注 |
|---|---|---|
| Compose 编译器 1.5.x | 默认关闭 | 需 composeCompiler { enableStrongSkippingMode = true } |
| Kotlin 2.0.20+ | 默认开启 | 可用 featureFlags 关闭 |
十二、@NonRestartableComposable
@NonRestartableComposable 告诉编译器:这个函数不需要生成可重启的入口。它的适用场景是那些本身不读取状态、只作为包装层存在的 Composable。
@NonRestartableComposable
@Composable
fun Label(text: String) = Text(text = text)
好处是减少生成的代码量与每次重组的检查开销。代价是:一旦函数内部真的读取了状态,状态变化时无法触发它自身的重组——它只能随父级重组被动刷新。因此这个注解只应用于"纯转发"的函数,且必须确认函数体内没有直接的状态读取。
常见误用是把 @NonRestartableComposable 加在页面级 Composable 上,结果状态更新后页面不刷新。判断标准很简单:函数内部是否直接读取 State,是则不要加。
十三、常见坑清单
| 坑位 | 现象 | 修正 |
|---|---|---|
把可变对象标 @Immutable | 界面不更新 | 改用 @Stable 或 mutableStateOf |
传 List<T> 作参数 | 无法跳过 | 用 ImmutableList 或包装类型 |
| 滚动位置在父级读取 | 整页重组 | 读取下沉到子作用域 |
derivedStateOf 包裹简单表达式 | 性能反降 | 仅在频率不对等时使用 |
lambda 未 remember | 子级不可跳过 | Kotlin 2.0 后由 strong skipping 处理 |
变化的 lambda 放进 DisposableEffect key | 副作用频繁重启 | key 用 Unit,值用 rememberUpdatedState |
CompositionLocal Provider 范围过大 | 大范围重组 | 缩小 Provider 作用域 |
| 重组中写状态 | 无限重组 | 移入 SideEffect 或回调 |
忽略 key 的身份语义 | 状态错位 | 分支切换与列表项都加 key |
盲目加 @NonRestartableComposable | 状态不刷新 | 仅用于纯转发函数 |
优化的正确顺序是:先用 Layout Inspector 定位真正的高频重组节点,再判断是"不该失效"还是"失效范围太大",最后才选工具。盲目地把所有参数标 @Immutable、把所有 lambda 包 remember,通常既没收益又引入新 bug。
重组优化的收益在冷启动与滚动场景中最明显,与 Android 启动性能优化 中的首帧分析思路可以相互参照;如果项目同时有 Flutter 端,Flutter 架构模式与最佳实践 中关于声明式框架状态管理的讨论也有类比价值。
小结
Compose 的重组优化可以归纳为四句话:
- 能跳过就跳过:稳定性是前提,
ImmutableList与@Stable是手段,strong skipping 是兜底。 - 失效范围要小:状态读取尽量下沉,
CompositionLocal的 Provider 尽量收窄。 - 高频输入要降频:
derivedStateOf压缩输出,snapshotFlow桥接协程。 - 先测量再优化:Layout Inspector 与 Composition Tracing 是唯一的真相来源。
一句话总结: 重组不是敌人,无谓的重组才是——把稳定性做对、把读取下沉、把高频状态降频,Compose 的性能问题基本就解决了一大半。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。