当你修改一个 State 的值时,Compose 如何知道哪些 UI 需要更新?更新是立即执行还是延迟处理?本文将深入探索 Recomposition 的完整调度与执行机制,从 Snapshot 系统到帧同步,揭秘状态变更如何触发 UI 更新。
I. 重组调度的整体流程
State 变更
│
▼
Snapshot 系统检测 ─────────────────────────────────────┐
│ │
▼ │
标记 InvalidationScope │
│ │
▼ │
Recomposer 调度 │
│ │
├── 同一帧内多次变更 ──→ 合并 (Coalescing) ────────────┘
│
▼
等待下一帧 (Frame Synchronization)
│
▼
执行 Recomposition
│
├── 遍历失效的 Scope
├── 重新执行 @Composable 函数
├── 更新 Slot Table
└── 应用变更到 Node Tree
│
▼
UI 渲染
II. Snapshot 系统:变更检测
2.1 什么是 Snapshot
Snapshot 系统是 Compose 的变更检测机制。它追踪所有 State 的读取和写入操作:
// MutableState 的简化实现
class SnapshotMutableStateImpl<T>(
value: T
) : MutableState<T> {
private var next: StateRecord // 状态记录链表
override var value: T
get() {
// 读取时通知 Snapshot 系统
val snapshot = Snapshot.current
snapshot.recordReadOf(this) // ← 记录读取!
return readable(next, snapshot).value
}
set(newValue) {
// 写入时通知 Snapshot 系统
val snapshot = Snapshot.current
val record = writable(next, snapshot)
record.value = newValue
snapshot.recordWriteOf(this) // ← 记录写入!
}
}
2.2 读取追踪
当 @Composable 函数读取 State 时,Snapshot 系统会记录这个依赖关系:
@Composable
fun Counter() {
// 1. 当前 RecomposeScope 被记录
val scope = currentRecomposeScope
// 2. 读取 count.value 时:
// Snapshot.recordReadOf(countState)
// → 建立 countState 与 scope 的关联
val count = countState.value
Text("Count: $count")
}
// 内部维护的依赖图:
// countState → [RecomposeScope@Counter]
// 当 countState 写入时,通知 Counter 需要重组
2.3 写入通知
// 当写入 State 时
count.value = count.value + 1
// Snapshot 系统执行:
fun recordWriteOf(state: StateObject) {
// 1. 找到所有读取过这个 State 的 Scope
val scopes = derivedStateObservers[state]
// 2. 通知每个 Scope 失效
scopes.forEach { scope ->
scope.invalidate()
}
}
// RecomposeScope.invalidate()
fun invalidate() {
// 将自己添加到 Composition 的失效列表
composition.recordInvalidation(this)
}
III. Recomposer:调度中枢
3.1 Recomposer 的角色
Recomposer 是重组的调度器,负责协调多个 Composition 的重组时机:
class Recomposer(
effectCoroutineContext: CoroutineContext
) : CompositionContext() {
// 管理的所有 Composition
private val knownCompositions = mutableListOf<Composition>()
// 需要重组的 Composition
private val compositionsNeedingRecompose =
mutableSetOf<Composition>()
// 帧调度器
private val broadcastFrameClock: BroadcastFrameClock
// 运行重组循环
suspend fun runRecomposeAndApplyChanges() {
// 无限循环,等待帧信号
while (true) {
// 等待下一帧
broadcastFrameClock.awaitFrame()
// 执行所有待处理的重组
performRecompose()
}
}
}
3.2 帧同步机制
Android 帧调度 (16.6ms @ 60fps):
Frame N Frame N+1 Frame N+2
│ │ │
▼ ▼ ▼
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ Input │ │ Input │ │ Input │
│ Animation │ │ Animation │ │ Animation │
│ Recomposition │ │ Recomposition │ │ Recomposition │
│ Layout │ │ Layout │ │ Layout │
│ Draw │ │ Draw │ │ Draw │
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
│ │
│ State 变更发生在这里 │
│ ↓ │
│ count = 1 │
│ count = 2 ← 同帧多次变更 │
│ count = 3 ← 会被合并! │
│ │
└────────────────────────────┘
下一帧才执行重组
只重组一次,使用最新值 count=3
3.3 变更合并 (Coalescing)
@Composable
fun BatchUpdates() {
var a by remember { mutableStateOf(0) }
var b by remember { mutableStateOf(0) }
Button(onClick = {
// 这三个变更都在同一帧内
a = 1 // 标记失效
b = 1 // 标记失效
a = 2 // 已失效,不重复标记
// 只会触发一次重组!
}) {
Text("a=$a, b=$b")
}
}
🔄 合并的意义
变更合并是重要的性能优化。无论一帧内发生多少次状态变更,Compose 都只在帧结束时执行一次重组。这避免了中间状态导致的无效渲染。
IV. InvalidationScope:失效追踪
4.1 RecomposeScope 的创建
// 每个 @Composable 函数编译后会包含 RecomposeScope
fun Counter($composer: Composer, $changed: Int) {
// 开始一个可重组的作用域
$composer.startRestartGroup(123) // key = 123
// ... 函数体 ...
// 结束作用域,返回 ScopeUpdateScope
val scope = $composer.endRestartGroup()
// 注册重组回调
scope?.updateScope { nextComposer, force ->
// 当需要重组时,重新调用此函数
Counter(nextComposer, $changed or 1)
}
}
4.2 失效传播
组件树结构:
┌─────────────────────────────────────────────────────────────┐
│ App │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ │ │
│ Screen Header │
│ │ │ │
│ ┌──────┴──────┐ │ │
│ │ │ │ │
│ Counter List Title │
│ │ │
│ count State │
└─────────────────────────────────────────────────────────────┘
当 count 变更时:
1. count State 通知 Snapshot 系统
2. Snapshot 找到读取 count 的 Counter Scope
3. 标记 Counter 为失效
4. 只有 Counter 需要重组!
(Screen, Header, List, Title 都不受影响)
4.3 最小重组范围
@Composable
fun Parent() {
var count by remember { mutableStateOf(0) }
println("Parent recomposing") // 会打印
Column {
Text("Count: $count") // 读取 count
ExpensiveChild() // 不读取 count
}
Button(onClick = { count++ }) {
Text("+")
}
}
@Composable
fun ExpensiveChild() {
println("ExpensiveChild recomposing") // 也会打印!
// ...
}
// 问题:ExpensiveChild 也重组了,因为它在 Parent 的作用域内
⚠️ Lambda 陷阱
在上面的例子中,ExpensiveChild() 虽然不读取 count,但它在 Parent 的作用域内。Parent 重组时,会重新执行整个函数体,包括 ExpensiveChild 的调用。
4.4 优化:使用 Lambda 隔离
@Composable
fun OptimizedParent() {
var count by remember { mutableStateOf(0) }
Column {
// 将 count 读取限制在最小范围
CountDisplay(count) // 只有这里读取 count
ExpensiveChild() // 不会重组!
}
Button(onClick = { count++ }) {
Text("+")
}
}
@Composable
fun CountDisplay(count: Int) { // 参数传递,非直接读取
Text("Count: $count")
}
// 现在只有 CountDisplay 会重组
// ExpensiveChild 的参数未变,会被跳过
V. 执行阶段详解
5.1 重组执行流程
internal fun performRecompose() {
// 1. 获取需要重组的 Composition 列表
val toRecompose = compositionsNeedingRecompose.toList()
compositionsNeedingRecompose.clear()
// 2. 对每个 Composition 执行重组
for (composition in toRecompose) {
// 2.1 获取失效的 Scope 列表
val invalidScopes = composition.takeInvalidations()
// 2.2 按位置排序(深度优先顺序)
invalidScopes.sortBy { it.position }
// 2.3 执行每个失效 Scope 的重组
for (scope in invalidScopes) {
if (!scope.isValid) continue // 可能已被父级覆盖
// 调用 scope 注册的 updateScope lambda
scope.recompose()
}
// 2.4 应用变更到 Node Tree
composition.applyChanges()
}
// 3. 执行 Side Effects
runSideEffects()
}
5.2 跳过机制 (Skipping)
// Composer 的跳过逻辑
fun startRestartGroup(key: Int): Composer {
// 检查是否可以跳过
skipping = !inserting &&
!currentScope.isInvalidated &&
allParametersUnchanged()
return this
}
// 编译后的代码会检查 skipping
fun MyComponent(param: String, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(123)
if ($composer.skipping) {
// 跳过整个函数体!
$composer.skipToGroupEnd()
} else {
// 执行函数体
Text(param, $composer, 0)
}
$composer.endRestartGroup()
}
5.3 $changed 参数
编译器生成的 $changed 参数用于快速判断参数是否变化:
// $changed 是一个位掩码
// 每个参数占用 3 位:
// - bit 0: 值是否变化
// - bit 1: 是否是稳定类型
// - bit 2: 是否已检查过
fun Profile(
name: String, // 参数 1: bits 0-2
age: Int, // 参数 2: bits 3-5
avatar: ImageUrl, // 参数 3: bits 6-8
$composer: Composer,
$changed: Int
) {
// 检查 name 是否变化
val nameChanged = ($changed and 0b001) != 0
// 检查 age 是否变化
val ageChanged = ($changed and 0b001000) != 0
// 只有全部参数未变化时才能跳过
if (!nameChanged && !ageChanged && !avatarChanged) {
$composer.skipToGroupEnd()
return
}
// 执行重组...
}
VI. Side Effects 的执行时机
6.1 Effect 调度
// Side Effects 在重组完成后执行
private fun runSideEffects() {
// 1. 执行 SideEffect (同步)
pendingSideEffects.forEach { it() }
pendingSideEffects.clear()
// 2. 启动 LaunchedEffect 协程
pendingLaunchedEffects.forEach { effect ->
effectScope.launch { effect() }
}
pendingLaunchedEffects.clear()
}
6.2 Effect 执行顺序
帧内执行顺序:
┌─────────────────────────────────────────────────────────────────┐
│ Frame N │
├─────────────────────────────────────────────────────────────────┤
│ 1. Recomposition │
│ └── 执行 @Composable 函数 │
│ └── 更新 Slot Table │
│ └── 记录待执行的 Effects │
│ │
│ 2. Apply Changes │
│ └── 更新 Node Tree │
│ │
│ 3. Side Effects │
│ └── SideEffect { } 同步执行 │
│ └── LaunchedEffect 启动协程 │
│ └── DisposableEffect onDispose 清理 │
│ │
│ 4. Layout & Draw │
│ └── 测量、布局、绘制 │
└─────────────────────────────────────────────────────────────────┘
VII. 高级调度场景
7.1 嵌套重组
@Composable
fun NestedRecomposition() {
var a by remember { mutableStateOf(0) }
Column {
Text("a = $a")
// 子组件在重组期间修改状态
ChildThatModifiesState {
// 这会触发新的失效
// 但会被延迟到下一帧
a = 1
}
}
}
// Compose 不允许在重组期间直接修改导致新重组
// 变更会被收集,在当前重组完成后处理
7.2 derivedStateOf 优化
@Composable
fun FilteredList(items: List<Item>, filter: String) {
// 不使用 derivedStateOf:每次 items 或 filter 变化都重组
val filtered = items.filter { it.name.contains(filter) }
// 使用 derivedStateOf:只有结果变化时才重组
val filtered by remember(items, filter) {
derivedStateOf {
items.filter { it.name.contains(filter) }
}
}
// 假设 items = [A, B, C], filter = "A"
// 结果 = [A]
// 如果 items 变为 [A, B, C, D],filter 不变
// 结果仍然 = [A],不触发重组!
LazyColumn {
items(filtered) { ItemRow(it) }
}
}
7.3 快照隔离
// 使用 Snapshot.withMutableSnapshot 批量更新
fun batchUpdate() {
Snapshot.withMutableSnapshot {
// 这些变更在快照内是隔离的
state1.value = 1
state2.value = 2
state3.value = 3
// 快照结束时,所有变更一起应用
// 只触发一次失效通知
}
}
VIII. 调试重组
8.1 重组计数
@Composable
fun RecompositionCounter(label: String) {
val count = remember { mutableIntStateOf(0) }
SideEffect {
count.intValue++
println("$label recomposed ${count.intValue} times")
}
}
8.2 Layout Inspector
Android Studio 的 Layout Inspector 可以显示:
- 重组次数高亮
- Composition 树结构
- 各组件的重组计数
IX. 总结
🎯 核心要点
- Snapshot 系统 追踪 State 的读取和写入
- InvalidationScope 精确标记需要重组的范围
- Recomposer 调度重组,与帧同步
- 变更合并:同帧内多次变更只触发一次重组
- Skipping:参数未变化的组件自动跳过
- Side Effects 在重组完成后执行
理解重组调度机制,能够帮助你:
- 写出更高效的 Composable 函数
- 避免不必要的重组
- 正确使用 derivedStateOf 等优化 API
- 调试性能问题