Recomposition 调度与执行机制:从状态变更到 UI 更新

📅 2024-07-07 · ⏱ 32 min · 🔬 底层原理

当你修改一个 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 可以显示:

IX. 总结

🎯 核心要点

理解重组调度机制,能够帮助你: