深入 Compose 编译器:Slot Table 与位置记忆化

2024-04-25 · 30 min · 原理深度解析

当你写下一个简单的 @Composable 函数时,Compose 编译器在背后做了大量工作。理解这些底层机制不仅能帮助你写出更高效的代码,还能让你在遇到性能问题时知道从何下手。本文将深入探讨 Compose 编译器的核心:Slot Table 数据结构和位置记忆化(Positional Memoization)机制。

📚 官方参考
Thinking in Compose - Android Developers
Compose Runtime Source Code

一、为什么需要 Slot Table?

传统的 UI 框架(如 Android View 系统)使用对象树来表示 UI 结构。每个 View 是一个对象,持有自己的状态,通过父子引用形成树形结构。这种方式直观但有几个问题:

Compose 采用了完全不同的方式:它不创建 UI 对象树,而是将所有 UI 信息存储在一个扁平的数组结构中——这就是 Slot Table

💡 核心洞察

Slot Table 本质上是一个线性化的 UI 描述。它把树形结构"压扁"成数组,用位置信息来隐式表达父子关系。这种设计借鉴了编译器和数据库领域的技术。

二、Slot Table 的数据结构

Slot Table 由两个主要数组组成:

  1. Groups Array:存储结构信息(哪些 Composable 被调用、它们的层级关系)
  2. Slots Array:存储数据(状态值、remember 的值、参数等)
┌─────────────────────────────────────────────────────────────┐
│                      Slot Table                            │
├─────────────────────────────────────────────────────────────┤
│  Groups Array (结构信息)                                    │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐       │
│  │ G0 │ G1 │ G2 │ G3 │    │    │    │G4 │ G5 │ G6 │       │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘       │
│                        ↑ Gap                               │
├─────────────────────────────────────────────────────────────┤
│  Slots Array (数据)                                         │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐       │
│  │ S0 │ S1 │ S2 │    │    │    │S3 │ S4 │ S5 │ S6 │       │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘       │
│                   ↑ Gap                                    │
└─────────────────────────────────────────────────────────────┘

Group 的结构

每个 Group 代表一次 Composable 调用或一个逻辑分组,包含以下信息:

// 简化的 Group 结构(实际实现更复杂)
internal class Group {
    val key: Int           // 唯一标识符,用于匹配
    val dataAnchor: Int    // 指向 Slots Array 中数据的起始位置
    val parentAnchor: Int  // 父 Group 的索引
    val size: Int          // 子 Group 的数量
    val nodeCount: Int     // 包含的 LayoutNode 数量
}
📚 源码参考
SlotTable.kt - Compose Runtime

三、Gap Buffer 算法

Slot Table 使用 Gap Buffer 算法来高效处理插入和删除操作。这个算法最初来自文本编辑器(如 Emacs),用于处理光标位置的文本编辑。

Gap Buffer 的工作原理

Gap Buffer 在数组中维护一个"空隙"(Gap),所有的插入/删除操作都在 Gap 的位置进行:

初始状态:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │   │   │   │ D │ E │ F │
└───┴───┴───┴───┴───┴───┴───┴───┴───┘
            ↑ Gap (cursor)

在 C 后插入 X:(O(1) 操作)
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ X│ D │ E │ F │
└───┴───┴───┴───┴───┴───┴───┴───┴───┘
                ↑ Gap moved

移动到 E 后插入 Y:(需要先移动 Gap)
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ X │ D │ E │ Y   │ F │
└───┴───┴───┴───┴───┴───┴───┴───┴───┘
                            ↑ Gap

Gap Buffer 的优势在于:

🔬 为什么选择 Gap Buffer?

Compose 的 Recomposition 具有很强的局部性:当状态变化时,通常只有一小部分 UI 需要更新,而且这些更新往往是连续的。Gap Buffer 正好利用了这一特性,在大多数情况下提供 O(1) 的更新性能。

四、位置记忆化(Positional Memoization)

这是 Compose 最核心的创新之一。传统的记忆化(Memoization)基于参数值来缓存结果,而 Compose 的位置记忆化基于调用位置

传统记忆化 vs 位置记忆化

// 传统记忆化:基于参数值
val cache = mutableMapOf<Args, Result>()
fun memoized(args: Args): Result {
    return cache.getOrPut(args) { compute(args) }
}

// 位置记忆化:基于调用位置
@Composable
fun Example() {
    // 即使参数相同,这两个 remember 存储在不同位置
    val state1 = remember { mutableStateOf(0) }  // 位置 A
    val state2 = remember { mutableStateOf(0) }  // 位置 B
    // state1 和 state2 是独立的!
}

编译器如何实现位置记忆化

Compose 编译器会为每个 @Composable 函数注入额外的参数和代码:

// 你写的代码
@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

// 编译器转换后(简化版)
fun Greeting(
    name: String,
    $composer: Composer,    // 注入的 Composer
    $changed: Int            // 参数变化标记
) {
    $composer.startRestartGroup(123456789)  // 唯一的 Group Key
    
    // 检查是否可以跳过
    if ($changed == 0 && $composer.skipping) {
        $composer.skipToGroupEnd()
    } else {
        Text("Hello, $name", $composer, 0)
    }
    
    $composer.endRestartGroup()?.updateScope { 
        Greeting(name, $composer, $changed or 1) 
    }
}

Group Key 的生成

编译器为每个 Composable 调用生成唯一的 Group Key,这个 Key 基于:

// 这两个 Text 有不同的 Group Key
@Composable
fun TwoTexts() {
    Text("First")   // Key: hash("TwoTexts.kt:5:4")
    Text("Second")  // Key: hash("TwoTexts.kt:6:4")
}

⚠️ 条件语句的陷阱

位置记忆化意味着调用顺序很重要。如果在条件语句中调用 Composable,条件变化可能导致位置错乱:

// ❌ 危险:条件变化会导致状态错乱
@Composable
fun Problematic(showFirst: Boolean) {
    if (showFirst) {
        Counter()  // 位置 0
    }
    Counter()      // showFirst=true 时位置 1,false 时位置 0!
}

// ✅ 使用 key 明确身份
@Composable
fun Safe(showFirst: Boolean) {
    if (showFirst) {
        key("first") { Counter() }
    }
    key("second") { Counter() }
}

五、Recomposition 的执行过程

当状态变化触发 Recomposition 时,Compose 执行以下步骤:

Recomposition 流程

┌─────────────────┐
│  State Changed  │
└────────┬────────┘
         ▼
┌─────────────────┐
│ Find Invalid    │  ← 找到读取该状态的 Scope
│ RecomposeScope  │
└────────┬────────┘
         ▼
┌─────────────────┐
│ Seek to Group   │  ← 移动 Gap 到对应位置
│ in Slot Table   │
└────────┬────────┘
         ▼
┌─────────────────┐
│ Re-execute      │  ← 重新执行 Composable
│ Composable      │
└────────┬────────┘
         ▼
┌─────────────────┐
│ Compare & Diff  │  ← 比较新旧数据
└────────┬────────┘
         ▼
┌─────────────────┐
│ Update Slot     │  ← 更新变化的部分
│ Table           │
└────────┬────────┘
         ▼
┌─────────────────┐
│ Apply Changes   │  ← 应用到 LayoutNode
│ to UI           │
└─────────────────┘

Composer 的读写模式

Composer 在执行 Composable 时有两种模式:

internal class Composer {
    var inserting: Boolean = false
    
    // inserting = true: 首次组合,写入新数据
    // inserting = false: 重组,读取并比较
    
    fun remember<T>(calculation: () -> T): T {
        return if (inserting) {
            // 首次:计算并存储
            val value = calculation()
            updateValue(value)
            value
        } else {
            // 重组:从 Slot Table 读取
            nextSlot() as T
        }
    }
}

六、$changed 参数与跳过优化

编译器注入的 $changed 参数是一个位掩码,用于追踪哪些参数发生了变化:

// $changed 位掩码结构
// 每个参数占用 3 位:
// - bit 0: 是否"脏"(需要检查)
// - bit 1-2: 稳定性信息

@Composable
fun Example(
    a: Int,      // bits 0-2
    b: String,   // bits 3-5
    c: User      // bits 6-8
)

// 调用时:
Example(
    a = 1,
    b = "hello",
    c = user,
    $composer = composer,
    $changed = 0b_000_001_000  // 只有 b 变化了
)

通过 $changed 参数,Compose 可以在不执行函数体的情况下判断是否需要重组:

// 编译器生成的跳过逻辑
if ($changed and 0b111_111_111 == 0 && $composer.skipping) {
    // 所有参数都没变,跳过整个函数
    $composer.skipToGroupEnd()
    return
}
📚 深入阅读
Jetpack Compose Internals - Jorge Castillo
Under the hood of Jetpack Compose - Android Developers Blog

七、实际应用:优化你的代码

理解了这些原理,我们可以写出更高效的 Compose 代码:

1. 利用位置记忆化

// ✅ 好:remember 的值会被正确保留
@Composable
fun GoodExample() {
    val state = remember { ExpensiveObject() }
    // state 只在首次组合时创建
}

// ❌ 差:每次重组都创建新对象
@Composable
fun BadExample() {
    val state = ExpensiveObject()  // 没有 remember!
}

2. 保持调用顺序稳定

// ✅ 好:使用 key 保持身份
@Composable
fun ItemList(items: List<Item>) {
    Column {
        items.forEach { item ->
            key(item.id) {  // 明确的身份
                ItemRow(item)
            }
        }
    }
}

// ❌ 差:依赖隐式位置
@Composable
fun ItemList(items: List<Item>) {
    Column {
        items.forEach { item ->
            ItemRow(item)  // 列表重排序会导致状态错乱
        }
    }
}

3. 减少 Group 数量

// ✅ 好:内联简单逻辑
@Composable
fun Efficient(text: String) {
    Text(
        text = text,
        color = if (text.isEmpty()) Color.Gray else Color.Black
    )
}

// ❌ 差:不必要的嵌套 Composable
@Composable
fun Inefficient(text: String) {
    TextWithColor(text)  // 额外的 Group 开销
}

@Composable
fun TextWithColor(text: String) {
    Text(
        text = text,
        color = if (text.isEmpty()) Color.Gray else Color.Black
    )
}

八、调试技巧

查看编译器输出

你可以配置 Compose 编译器输出中间代码:

// build.gradle.kts
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

使用 Layout Inspector

Android Studio 的 Layout Inspector 可以显示 Recomposition 计数,帮助你找到频繁重组的组件。

总结

Compose 编译器的核心设计:

理解这些底层机制,能帮助你:

📚 推荐阅读
Compose Performance - Android Developers
Compose Runtime Deep Dive - Android Dev Summit
Jetpack Compose Internals Book