Compose 状态管理与重组优化

重组性能是 Compose 项目最常见的瓶颈,很多卡顿都源于不必要的重复重组。本文讲清重组的触发条件、稳定性判定规则与稳定注解的语义边界、集合与数据类不稳定造成的多余重组、键与派生状态等优化手段、快照流的用法与组合局部变量的代价,以及如何用布局检查器与组合追踪定位问题,并覆盖新版编译器的强跳过模式。

一、重组的本质

Compose 把界面描述成一个函数。状态变化时,框架不会重建整棵视图树,而是重新执行读取了该状态的 Composable 函数,并对比新旧的 UI 结构决定要更新哪些渲染节点。这个过程就叫重组(recomposition)。

理解重组的关键是三个概念:

  • 作用域(scope):每个 @Composable 函数调用点都是一个可独立重组的范围。
  • 跳过(skip):如果一次重组中,某作用域的所有参数都没变,框架可以跳过它的执行。
  • 失效(invalidate):当 State 的 value 被写入,所有在组合期间读取过它的作用域被标记为失效,等待下一帧重组。

因此"优化重组"的本质只有两条路:让不该执行的作用域被跳过,或者让失效的作用域尽量小。前者靠稳定性,后者靠状态下沉。

概念含义优化手段
作用域一次 Composable 调用拆分函数、状态下沉
跳过参数未变则不执行稳定性、key
失效读取过的 State 被写入derivedStateOf、snapshotFlow
帧调度失效在一帧内合并执行减少无效写入

与 View 体系对比:View 的 invalidate() 触发的是重绘(draw),而重组是重新执行逻辑,可能级联触发子作用域。二者的成本模型完全不同,这也是为什么 Compose 的优化点在"少执行"而非"少绘制"。

一句话总结:重组优化的全部工作,就是让"该跳过的作用域能跳过,该小的失效范围足够小"。


二、重组触发条件

一次重组只会由以下几类事件发起:

  1. State 的值发生写入,且新值不等于旧值(State 使用 equals 做结构相等判断)。
  2. 父作用域重组,且本作用域不满足跳过条件(参数变化或不可跳过)。
  3. CompositionLocal 提供的值变化,所有读取它的作用域失效。
  4. 外部强制刷新,如 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输出主要用途
derivedStateOfState<T>降低重组频率
snapshotFlowFlow<T>桥接到协程、副作用
rememberUpdatedStateState<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 的重组优化可以归纳为四句话:

  1. 能跳过就跳过:稳定性是前提,ImmutableList 与 @Stable 是手段,strong skipping 是兜底。
  2. 失效范围要小:状态读取尽量下沉,CompositionLocal 的 Provider 尽量收窄。
  3. 高频输入要降频:derivedStateOf 压缩输出,snapshotFlow 桥接协程。
  4. 先测量再优化:Layout Inspector 与 Composition Tracing 是唯一的真相来源。

一句话总结: 重组不是敌人,无谓的重组才是——把稳定性做对、把读取下沉、把高频状态降频,Compose 的性能问题基本就解决了一大半。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试