前几天,我的好同事彭于晏读完我前面几篇关于 Compose 的文章后,准备在组内好好推广一下,做一次分享(毕竟也有 KPI)。
他问了我一个很真实的问题:有没有什么技巧,讲出来能让大家觉得“这个人确实懂 Compose,而且还挺厉害”?
我想了想,答案还真有。
不是背 API,也不是堆概念,而是能说清楚:Compose 为什么会重组,状态在哪里被读取,以及如何控制它的影响范围。
好,正文开始!
Jetpack Compose 遵循"状态变更 → 重组 → UI 更新"的声明式范式。
如果状态处理不当会引发不必要的重组,直接拖累页面性能。
本文通过错误示例与正确示例的对比,介绍三种减少重组的实战技巧。
正确处理快速变化
❌ 错误示例
val showTopBar = lazyListState.firstVisibleItemIndex > 20
有时我们会从一个状态派生出另一个状态。
如果源状态变化频率很高(比如滚动中的 LazyListState),就会导致重组过于频繁。Android Studio 也会对此显示警告。
✅ 正确示例
// 使用 derivedStateOf
val showTopBar by remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 20 }
}
// 使用 snapshotFlow
var showTopBar by remember { mutableStateOf(false) }
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.firstVisibleItemIndex }
.map { it > 20 }
.distinctUntilChanged()
.collect { visible -> showTopBar = visible }
}
两种方案各有所长:
derivedStateOf:在内部定义计算代码块,源状态变化时自动产出派生结果,适合纯粹的状态派生场景。snapshotFlow:将状态转为Flow,可自定义数据处理管线(map、distinctUntilChanged等),适合需要异步处理或触发副作用(如Toast)的场景。
选择原则:
- 如果只是通过一个状态生成另一个值,中间不需要额外的处理过程或管线,用
derivedStateOf; - 如果需要处理副作用、接入处理管线,或存在更复杂的中间处理过程,用
snapshotFlow。
避开组合阶段
❌ 错误示例
Text(
modifier = Modifier
.align(Alignment.Center)
.offset(y = 60.dp * animatedValue) // 组合阶段读取状态
.scale(animatedValue), // 组合阶段读取状态
text = "Animate"
)
使用状态配合 Modifier 动态改变位置、大小、透明度是常见做法。
但如果在组合阶段(Composition)直接读取快速变化的状态(如动画值、滚动偏移量),每次状态更新都会迫使 Compose 重新执行整个 Composable 函数,引发高频重组。
✅ 正确示例
Text(
modifier = modifier
.offset { IntOffset(0, (60 * animatedValue).toInt()) } // 布局阶段读取
.drawWithContent { // 绘制阶段读取
scale(scale = animatedValue) {
this@drawWithContent.drawContent()
}
},
text = "Animate"
)
// 或者更推荐的图形变换方式:
Text(
modifier = modifier
.offset { IntOffset(0, (60 * animatedValue).toInt()) } // 布局阶段读取
.graphicsLayer { // 绘制阶段读取
scaleX = animatedValue
scaleY = animatedValue
},
text = "Animate"
)
这里的核心思路是阶段降级:将状态的读取从“组合阶段”推迟到后续的渲染阶段。
- 布局阶段(Layout) 读取:使用 Lambda 形式的
Modifier(如offset { }) - 绘制阶段(Draw) 读取:使用
drawWithContent或graphicsLayer { }
为什么阶段降级能提升性能?
Compose 的渲染流水线分为三个严格的先后阶段:组合(Composition)→ 布局(Layout)→ 绘制(Draw)。
- 组合阶段负责根据状态构建 UI 树。如果在这里读取状态,状态变化必定导致 UI 树重建(即触发重组)。
- 布局阶段仅安排组件位置,绘制阶段仅负责将像素渲染到屏幕。
当我们在 offset { } 或 graphicsLayer { } 的 Lambda 内部读取状态时,Compose 会智能地跳过组合阶段。状态变化时,只会触发对应组件的重新布局或重新绘制,从而彻底省去了最耗时的重组开销。
此法在此文中亦有提及。
缩小重组作用域
如果说上一个技巧是解决“在什么时间点读取”的问题,那么这个技巧则是解决“在 UI 树的什么位置读取”的问题。
⚠️ 欠佳示例
@Composable
private fun Parent() {
var count by remember { mutableIntStateOf(0) }
// 父组件直接读取了 count 状态
// count 变化时,Parent 会发生完整重组,连带其内部的其他子组件也可能受影响
ChildA(count = count)
OtherStaticChild()
}
@Composable
private fun ChildA(count: Int) {
Text("求关注 RockByte 公众号: $count")
}
在 Compose 中,重组的最小单位是 Composable 函数。Compose 会精准追踪每个状态被哪些 Composable 读取了。
在上述代码中,由于 count 是在 Parent 的作用域内被读取(作为参数传递给 ChildA),一旦 count 发生变化,Parent 就会被标记为需要重组。这会导致 Parent 内部的 OtherStaticChild() 等无关组件也面临被重新评估的风险。
✅ 优化示例
@Composable
fun Parent() {
val count = remember { mutableStateOf(0) }
// 传递 Lambda,Parent 并没有真正读取状态的值
ChildB(countProvider = { count.value })
OtherStaticChild()
}
@Composable
fun ChildB(countProvider: () -> Int) {
// 状态的读取被推迟到了 ChildB 的内部
Text("求关注 RockByte 公众号: ${countProvider()}")
}
通过将直接传值改为传递 Lambda,我们成功地将状态的读取动作从父组件“下放”到了真正需要它的子组件中。
此时,Parent 只是传递了一个函数引用,并没有调用 count.value。当状态变化时,只有调用了该 Lambda 的 ChildB 会发生重组,Parent 及其它兄弟组件则安然无恙。
避坑指南:不要过度设计
上述代码并无大问题(甚至可以说没有问题!),这里只是展示一种防止大范围重组的技巧。当然还有一种方法是 ChildB 内部自己处理状态的变化,不当做一个可以移植的组件。
官方架构指南的基石是状态提升(State Hoisting),传递纯数据(如 Int)能让组件完全无状态化,获得最好的可测试性和复用性。
因此,空间隔离技巧不应被滥用。只有当状态变化频率极高,或者你需要跨越深层组件树进行透传时,才推荐使用 Lambda 包装。
对于普通的点击计数、表单输入等低频状态,常规的直接传值依然是代码可读性最高的最佳实践。
原理:Compose 的快照追踪系统
Compose 的状态追踪由底层的 Snapshot System(快照系统) 驱动。每次状态的 getter 被调用时,都会被注册到当前的快照中。快照系统检测到状态变化后,会通知 Compose 运行时重新执行读取了该状态的 Composable。
核心逻辑在 mutableStateOf 的源码中:
override var value: T
get() = next.readable(this).value // 触发读取记录
set(value) =
next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
state.value 的读取会触发 readObserver,精准记录下“当前是哪个 Composable 作用域在读取我”:
public fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(state) // 快照系统记录读取者 (Reader)
return readable(this, snapshot.snapshotId, snapshot.invalid)
?: sync {
val syncSnapshot = Snapshot.current
@Suppress("UNCHECKED_CAST")
readable(state.firstStateRecord as T, syncSnapshot.snapshotId, syncSnapshot.invalid)
?: readError()
}
}
正是基于这套机制,Compose 能够建立起“状态 → Composable”的订阅关系。我们通过 Lambda 改变了 getter 被调用的物理位置,也就改变了快照系统记录的订阅者,从而实现了重组作用域的精准控制。
总结
你会发现,Compose 的性能优化永远围绕重组展开,虽然重组本身不会造成严重的性能问题(重组正是 Compose 的核心理念),但关注并优化重组行为,能从源头上消除潜在的性能隐患。
需要注意的是,重组本身不是问题,问题通常出在状态读取范围过大、读取频率过高,或把高频状态提前放到了组合阶段读取。优化 Compose 性能时,更重要的是理解状态在哪里被读取,而不是机械地追求"少重组"。
先记住三条核心原则:
- 派生与过滤——高频变化的状态用
derivedStateOf或snapshotFlow控制更新粒度,避免无意义的重组; - 阶段降级——利用
offset { }、graphicsLayer { }等 Lambda 修饰符,将状态读取推迟到布局或绘制阶段,彻底避开组合阶段; - 空间隔离——通过传递 Lambda 函数代替直接传值,将状态读取的作用域缩小到真正需要它的子组件内。
能把这三点讲清楚,你的同时听到的就不只是“少重组”这句口号,而是你真正理解了 Compose 的运行方式。