什么?Compose 把 GapBuffer 换成了 LinkBuffer?

110 阅读35分钟

倘若你略微了解过 Compose Runtime,可能会知道它使用了 GapBuffer 这一数据结构来构建其 SlotTable,后者存储了 Composition 中的各类重要信息,但如今它却要被替换了!这中间发生了什么呢,咱们一起来看看。

本文参考自 compose-slottable-gap-to-link 公开部分,基于 Jetpack Compose 1.11.0-alpha06 源码,随代码更新部分内容可能有变。编写过程中由我(Fish) 和 GPT/Gemini/Claude 共同完成

本文内容较为底层,读者可以选择看个乐呵,或者看看里面位压缩的技巧,或许能有所启发

速览:什么是 GapBuffer/SlotTable/Composition

请看下面的代码:

@Composable
fun Counter() {
   var count by remember { mutableStateOf(0) }
   Text("Count: $count")
   Button(onClick = { count++ }) {
       Text("Increment")
   }
}

当你作为一名 Compose 新手时,你会学到,它定义了个 Compose 的状态,并且 remember 了它。点击按钮时,它会自动更新状态,然后重新渲染 UI。

而当你逐步精进,头发日益稀疏时,你可能会了解到下面的代码编译后会生成类似如下的结构:

// 注意:这是“长得像”的伪代码,省略了大量细节(参数、标记位、内联、稳定性推断等)。
fun Counter(composer: Composer, changed: Int) {
    composer.startRestartGroup(/* key = ... */)
    
    if (composer.shouldExecute(changed != 0, changed & 1)) {

        // 1) `remember` 的状态并不会挂在某个 View/Node 上,而是挂在“这个调用位置”上
        //    (更精确地说:它也会对应到某个 group + slot。)
        val countState = composer.cache(/* key = ... */) { mutableStateOf(0) } as MutableState<Int>

        // 2) 每个 Composable 调用点(例如 Text / Button)也都会展开成一个 group

        composer.startReplaceGroup(/* key for Text("Count") = ... */)
        Text("Count: ${countState.value}", composer, /* changedFlags = ... */)
        composer.endReplaceGroup()

        composer.startReplaceGroup(/* key for Button = ... */)
        Button(
            onClick = { countState.value++ },
            composer = composer,
            /* changedFlags = ... */
        ) {
            // `Button` 的 content lambda 同样会形成自己的 group 边界。
            composer.startReplaceGroup(/* key for Button.content = ... */)
            Text("Increment", composer, /* changedFlags = ... */)
            composer.endReplaceGroup()
        }
        composer.endReplaceGroup()
    } else {
        composer.skipToGroupEnd()
    }

    // 3) 结束 group,并留一个“如何重启”的回调:状态变化时可以从这里开始重新执行
    composer.endRestartGroup()?.updateScope { c, f ->
        Counter(c, f)
    }
}

到这一步,你可能能大致了解下面这俩件事情:

第一,@Composable 不是简单的 “返回一个 UI 树” 的函数。它更像是在一次执行过程中,按顺序对 composer 说:我这里调用了 Text,接着调用了 ButtonButton 里面又调用了一个 Text……这条调用轨迹,才是 Compose 运行时真正关心的结构。

第二,remember 之所以能“记住”,不是因为它捕获了某个对象的引用,而是因为运行时会把“在某个调用位置创建出来的值”存起来,放到 SlotTable 里;下次重组(recompose)走到同一个调用位置时,就能取回它。

把这两点合在一起,可以粗略对 Composition / SlotTable / GapBuffer 下点定义:

  • Composition:组合,可以把它理解成“一段可重复执行的 UI 程序 + 它的运行时状态”。每次重组就是把这段程序再跑一遍,但不是从零开始瞎跑。
  • SlotTable:是运行时保存“上一次执行的结构 + 关联状态”的地方。结构通常以一组 group(组)来表示:哪个调用开始了、哪个调用结束了、有哪些 key、有哪些 remember 的值、有哪些节点引用……都在这里。
  • GapBuffer:则是 SlotTable 内部用来高效做插入/删除/移动的一种实现选择。因为你的代码结构可能会变:比如 if (count > 0) { ... } 让某段 UI 有时存在、有时不存在;这意味着 SlotTable 里对应的 group 需要频繁插入、删除或挪动。

每一次 Composable 函数调用、每一个 remember 的值、每一个 Key,都被记录为这张表中的"组(Groups)"和"槽(Slots)"。

多年来,SlotTable 一直使用 Gap Buffer(间隙缓冲区)——它是驱动 Emacs 等文本编辑器的经典数据结构。它在处理顺序写入时表现卓越,但随着应用变得越来越动态——复杂的列表、动画、条件内容(Movable Content)——一个痛点逐渐显现:移动或重排组(Group)需要复制大量内存

所以本文的标题出现了:Compose 团队在最近开始将 SlotTable 重写为基于 Link Buffer(链表缓冲区)的结构。这一变更使得像列表重排这样的操作,其重组(Recomposition)速度提升了两倍以上。

让我们来瞅一瞅吧


Gap Buffer:从文本编辑器到 UI 树

说到这个 Gap Buffer 啊,它原来是为了解决文本编辑问题而生的。想象一下,文本文档是一个字符序列,用户可能在任何位置插入或删除。如果用普通数组存储,每次在中间插入字符,都需要把插入点之后的所有字符向后挪动。对于一个 10 万字的文档,如果在开头打字,就意味着要移动近 10 万次内存。这非常的不可理喻啊!

于是 Gap Buffer 出现了。它通过在光标位置维护一段"空闲区域(Gap)"解决了这个问题。在光标处输入,只是填补 Gap;删除字符,则是扩大 Gap。由于大多数编辑操作都是局部的、连续的,Gap 始终伴随着光标,性能极佳。

但是凡事都有例外——当光标需要跳跃到远处时,Trade-off(代价)就出现了。Gap 必须"滑"到新的位置,这意味旧位置和新位置之间的所有数据都要被复制搬运。所以我们可以看见:只要编辑操作聚集在某处,性能就很好;一旦频繁随机跳转,开销便无法忽视。

Compose 1.0 的选择

在本文提到的 LinkBuffer 出现之前,Compose 的 SlotTable 由两个扁平数组构成:

下文提到的代码均位于 {androidx-main}/frameworks/support/compose/runtime/

// composer/gapbuffer/SlotTable.kt
internal class SlotTable : SlotStorage(), CompositionData, Iterable<CompositionGroup> {
    var groups = IntArray(0)            // 结构数组,每 5 个 Int 描述一个 Group
    var slots = Array<Any?>(0) { null } // 数据数组,存储 remember 值、Node 实例等
    internal var anchors: ArrayList<GapAnchor> = arrayListOf() // 外部持有的位置句柄
    // ...
}

简单解释下 anchors 字段:在 Gap Buffer 的架构下,Group 的身份是由它在数组中的物理位置(index)决定的——但当插入、删除或移动操作发生时,数组内容会发生位移,这意味着"第 42 个 Group"可能会挪到"第 50 个位置"。

为了让外部世界(例如 MovableContent 或 Tooling API)能够稳定地引用某个 Group,Compose 引入了 Anchor 机制。每个 Anchor 内部持有一个 location 字段,它会在数组变化时自动更新:

// composer/gapbuffer/GapAnchor.kt
internal class GapAnchor(loc: Int) : Anchor {
    internal var location: Int = loc  // 相对 gap 的位置,运行时会随数组操作自动调整

    override val valid
        get() = location != Int.MIN_VALUE  // 如果 Group 被删除,location 会被标记为 MIN_VALUE

    fun toIndexFor(slots: SlotTable) = slots.anchorIndex(this)
    fun toIndexFor(writer: SlotWriter) = writer.anchorIndex(this)
}

当调用 SlotWriter.moveGroup(...)removeGroup(...) 时,运行时会遍历 anchors 列表,逐个修正受影响的 location 值。这保证了外部引用的稳定性——但代价是 O(N) 的 Anchor 更新开销,尤其是当树很大、Anchor 很多时,这会成为性能瓶颈(后文会看到这正是"9 步移动法"中的第 6 步)。

Groups 数组布局

groups 数组中的每个 Group 占用 5 个整数(Group_Fields_Size = 5),它们紧密排列成一个"虚拟结构体":

// composer/gapbuffer/SlotTable.kt
// Group layout
//  0             | 1             | 2             | 3             | 4             |
//  Key           | Group info    | Parent anchor | Size          | Data anchor   |
private const val Key_Offset = 0
private const val GroupInfo_Offset = 1
private const val ParentAnchor_Offset = 2
private const val Size_Offset = 3
private const val DataAnchor_Offset = 4
private const val Group_Fields_Size = 5

这 5 个字段的含义如下:

  • Key[0]):Composable 的 sourceKey,编译器插桩生成,用于重组时的 diff。
  • GroupInfo[1]):位压缩的元数据,高 6 位为标志位,低 26 位为 nodeCount(见下方布局)。
  • ParentAnchor[2]):父 Group 的位置,采用 gap-relative 编码(即相对于当前 gap 的位置,随 gap 移动会自动更新)。
  • Size[3]):本 Group 及其所有子孙占用的 Group 总数(含自己)。这让"跳过整棵子树"变为一次加法:nextIndex = currentIndex + size
  • DataAnchor[4]):指向 slots 数组中该 Group 数据的起始位置,同样采用 gap-relative 编码。正值表示在 gap 之前,负值表示在 gap 之后;gap 移动时会批量更新。

为了节省内存,GroupInfo 采用单个 Int 的位域存储,布局如下:

// Group info bit layout (Int32):
// 31 30 29 28_27 26 25 24_23 22 21 20_19 18 17 16__15 14 13 12_11 10 09 08_07 06 05 04_03 02 01 00
// 0  n  ks ds m  cm|                                node count                                    |
// n  (bit 30) = isNode           — 该 Group 是否代表一个实际的 UI 节点(如 View / LayoutNode)
// ks (bit 29) = hasObjectKey     — 是否有 object key(非 Int key)
// ds (bit 28) = hasDataSlot      — 是否有额外的 group data slot(如 CompositionLocalMap)
// m  (bit 27) = isMark           — 标记位(用于 invalidation 扫描)
// cm (bit 26) = containsMark     — 子树中是否包含 mark
// [0..25]     = node count       — 该 Group 包含的 LayoutNode 数量(低 26 位)

这种结构是线性的:父节点的 5 个 Int 之后紧接着就是子节点的 5 个 Int,形成深度优先(Depth-First)的布局。

在 Gap Buffer 架构下,Composition 通常是顺序执行的,Gap 随着执行流移动,一切都很完美——但 Recomposition(重组)打破了这种宁静。它可能按任意顺序修改树的任何部分,尤其是列表重排,会让 Group 在数组中进行长距离跨越。

核心痛点:随规模膨胀的数组拷贝

想象一个包含 1000 个 Item 的 LazyColumn。用户将第 999 个 Item 拖拽到了第 0 个位置。在 Runtime 内部,这意味着要将该 Item 对应的 Group(及其所有子 Group 和 Slots)从表的末尾搬运到开头。

在 Gap Buffer 的实现中,这被称为 "9 步移动法"(The 9-step move),每一步都伴随着痛苦的内存操作:

  1. Insert Slots:在目标位置为 Slots 腾出空间(移动 Slot Gap,更新沿途所有 Anchor)。
  2. Insert Groups:在目标位置为 Groups 腾出空间(移动 Group Gap)。
  3. Copy Groups:将 Group 元数据复制到新位置。
  4. Copy Slots:将 Slot 数据复制到新位置。
  5. Fix Anchors (Moved):修正被移动 Group 内部的 Slot Anchor(因为 Slot 位置变了)。
  6. Update External Anchors:更新外部对象持有的 Anchor(因为 Group 位置变了)。
  7. Remove Old Groups:删除旧位置的 Group(再次移动 Gap)。
  8. Fix Parent Anchors:修正受影响的父节点信息。
  9. Remove Old Slots:最后删除旧位置的 Slot 数据。

让我们看看 Gap Buffer 中 moveGroup 的真实源码(经简化),感受一下这 9 步操作的沉重:

// composer/gapbuffer/SlotTable.kt — SlotWriter.moveGroup()
fun moveGroup(offset: Int) {
    // 沿数组线性跳跃,找到要移动的 group(需要用 Size 跳过子树)
    var groupToMove = currentGroup
    repeat(offset) { groupToMove += groups.groupSize(groupIndexToAddress(groupToMove)) }

    val moveLen = groups.groupSize(groupIndexToAddress(groupToMove))  // 含所有子 group
    val moveDataLen = dataEnd - dataStart  // 对应的 slot 数据量

    // ---- 9 步开始 ----
    insertSlots(moveDataLen, ...)     // 1. 移动 slot gap 到目标位置,腾出空间
    insertGroups(moveLen)             // 2. 移动 group gap 到目标位置,腾出空间
    groups.copyInto(groups, ...)      // 3. 自拷贝:group 元数据搬到新位置
    slots.fastCopyInto(slots, ...)    // 4. 自拷贝:slot 数据搬到新位置
    for (group in current until current + moveLen) {
        groups.updateDataIndex(...)   // 5. 逐个修正 group 内的 slot anchor
    }
    moveAnchors(...)                  // 6. 更新外部持有的 anchor 引用
    removeGroups(...)                 // 7. 删除旧位置的 group(再次移 gap)
    fixParentAnchorsFor(...)          // 8. 修正受影响的父节点
    removeSlots(...)                  // 9. 删除旧位置的 slot(必须最后)
}

顺序约束让整个操作既复杂又脆弱:插入 slots 必须在插入 groups 之前(移动 gap 需遍历 anchor,而 anchor 依赖 groups 合法),删除 groups 必须在删除 slots 之前(理由相同)。

这不仅仅是 System.arraycopy 的开销,更难受的是对 Anchors 的修正。如全文所述,Anchor 是外部世界(如 MovableContent 或 Tooling)指向表内部的句柄。一旦数组内容发生位移,所有相关的 Anchor 都必须同步更新——对于复杂 UI 树,这是 O(N) 级别的操作!

听起来就足够复杂了,因此当然得简化!于是 Link Buffer 就诞生了。


Link Buffer:当数组学会了"指针"

为了解决 Gap Buffer 的移动痛点,Compose 团队引入了 Link Buffer

名字里虽然带 "Link"(链表),但请不要误会,这可不是 Java 标准库里的 LinkedList<Node>。在高性能的 UI 框架中,创建成千上万个小对象(Node)是 GC 的噩梦。

Link Buffer 的核心在于:它依然使用扁平的 IntArray 来存储数据,但在逻辑上构建了一棵树。

新版 SlotTable 不再直接持有数组,而是委托给独立的 SlotTableAddressSpace

下方大量代码警告,如果需要阅读,请不要跳过,我已经补充了非常多的注释和例子,希望能帮助理解

// composer/linkbuffer/SlotTable.kt
internal class SlotTable(
    var root: Int = NULL_ADDRESS,                        // 根 Group 的地址(链表头),初始 -1
    val addressSpace: SlotTableAddressSpace = SlotTableAddressSpace(), // 真正的存储
) : SlotStorage(), CompositionData, Iterable<CompositionGroup> { ... }

// composer/linkbuffer/SlotTableAddresSpace.kt
internal class SlotTableAddressSpace(
    var groups: IntArray = newArray(SLOT_TABLE_INITIAL_GROUPS_SIZE),  // Group 结构数组,初始大小 6 * 1024,6 为单个 Group 大小
    var slots: Array<Any?> = arrayOfNulls(SLOT_TABLE_INITIAL_SLOTS_SIZE), // 数据数组,初始大小 1024。开始时均为特殊的 Unallocated 对象
) {
    private var _largeSizes: MutableIntIntMap? = null  // SlotRange 大数据查表(稍后详述)
    private var unallocatedStart = 0   // slots 的 bump allocation 水位线
    private var unallocatedEnd = slots.size
    private var freeSlotCount = 0      // 已释放但未整理的碎片数量
    private var anchors = mutableIntObjectMapOf<LinkAnchor>()
    // ...
}

1. 物理结构:6 个整数撑起一个 Group

SlotTableAddressSpace.kt 中,每个 Group 被固定为 6 个整数SLOT_TABLE_GROUP_SIZE = 6)。这 6 个整数紧密排列,构成了一个"虚拟结构体"。

如果我们看内存中的 groups 数组,它长这样:

Index:  0  1  2  3  4  5    6  7  8  9 10 11   ...
       [ Group 0 Data   ]  [ Group 1 Data   ]  ...

而这 6 个整数的定义和字段访问方式如下:

// ============ 类型定义 ============
// 类型别名——本质都是 Int,但通过别名提供语义标注
internal typealias GroupAddress = Int  // Group 在 groups 数组中的起始索引(总是 6 的倍数)
internal typealias SlotRange = Int     // 打包的 slots 引用(包含起始位置和长度)
internal const val NULL_ADDRESS = -1   // 哨兵值:表示空指针/无效地址

// ============ 物理布局常量 ============
// 每个 Group 在 IntArray 中占 6 个连续 Int
internal const val SLOT_TABLE_GROUP_SIZE = 6

// 6 个字段的详细说明:
// 偏移量  字段          说明
//   +0    Key           Composable 的 sourceKey(编译器生成的唯一标识)
//   +1    Next          下一个兄弟节点的地址(-1 表示"我是最后一个孩子")
//   +2    Parent        父节点的地址(用于向上遍历)
//   +3    Child         第一个子节点的地址(-1 表示"我是叶子节点")
//   +4    Flags         位掩码字段:
//                        • bit 0-22:  childNodeCount(此 Group 及其子树共有多少 LayoutNode)
//                        • bit 23:    IsNode(此 Group 是否代表一个 LayoutNode)
//                        • bit 24:    HasObjectKey(Key 是对象还是 Int)
//                        • bit 25+:   其他标志位(如 IsVirtual/HasAux 等)
//   +5    SlotRange     位压缩的 slots 引用:
//                        • 高 28 位:起始位置(此 Group 的数据在 slots 数组中从哪开始)
//                        • 低 4 位: 数据长度(占用多少个 slot,0xF 表示大对象)

// ============ 访问器函数(零开销抽象)============
// 这些 inline 函数在编译后会被内联,等价于直接操作数组下标
// 例如:groups.groupNext(12) 编译后就是 groups[12 + 1]

// 读取 Next 指针
internal inline fun IntArray.groupNext(address: GroupAddress): GroupAddress =
    this[address + 1]  // SLOT_TABLE_GROUP_NEXT_OFFSET = 1

// 写入 Next 指针
internal inline fun IntArray.groupNext(address: GroupAddress, value: Int) {
    this[address + 1] = value
}

// 读取 Child 指针
internal inline fun IntArray.groupChild(address: GroupAddress): GroupAddress =
    this[address + 3]  // SLOT_TABLE_GROUP_CHILD_OFFSET = 3

// 写入 Child 指针
internal inline fun IntArray.groupChild(address: GroupAddress, value: Int) {
    this[address + 3] = value
}

// 其他字段同理:groupKey / groupParent / groupFlags / groupSlotRange
// 都遵循相同模式:
//   fun IntArray.groupXXX(addr): Int         // getter
//   fun IntArray.groupXXX(addr, value: Int)  // setter

举个栗子:假设我们有一个 Button Composable,它在 groups 数组中从 index 18 开始:

// Button 的 Group 存储在 groups[18..23]:
groups[18] = 1234567    // Key (编译器生成的 sourceKey)
groups[19] = -1         // Next = NULL (它是父节点的最后一个孩子)
groups[20] = 12         // Parent = 12 (父节点在 index 12)
groups[21] = 24         // Child = 24 (第一个子节点在 index 24)
groups[22] = 0x00800001 // Flags = IsNode | childNodeCount=1
groups[23] = 0x00050003 // SlotRange = start:5, length:3 (slots[5..7] 是它的数据)

// 使用访问器读取:
val address = 18
val nextSibling = groups.groupNext(address)     // 返回 -1
val firstChild = groups.groupChild(address)     // 返回 24
val parent = groups.groupParent(address)        // 返回 12

// 修改指针(例如插入一个新兄弟节点):
groups.groupNext(address, 30)  // 现在 groups[19] = 30

2. 逻辑结构:隐式的树

通过 NextParentChild 这三个"指针"(实际上是数组索引),我们在扁平数组上构建了一棵完全动态的树。

假设我们有三个 Group:Parent (P), Child A, Child B。它们在数组里可能是乱序存放的:

物理内存布局 (groups 数组):
Address | Group | Next | Parent | Child | ...
=============================================
  100   |   P   |  -1  |  ...   |  200  | ...  (P 的孩子是 200/A)
  ...   | (其他无关数据)
  200   |   A   |  300 |  100   |  ...  | ...  (A 的兄弟是 300/B,父是 100/P)
  ...   | (其他无关数据)
  300   |   B   |  -1  |  100   |  ...  | ...  (B 没有兄弟,父是 100/P)

逻辑树结构(通过指针连接):

        P (地址 100)
        |
        | child 指针 → 200
        ↓
        A (地址 200)
        |
        | next 指针 → 300
        ↓
        B (地址 300)
        |
        | next 指针 → -1 (NULL)

在这个结构中:

  • 逻辑上是 P -> [A, B](P 有两个孩子 A 和 B)
  • 物理上 A 在 index 200,B 在 index 300,中间可以隔着 100 个其他 Group
  • 通过 NextChild 指针,我们在乱序的数组上构建了有序的树
  • 这意味着:移动、插入、删除都不需要拷贝内存,只需修改指针!

举个栗子:我们要在 UI 中渲染一个简单的界面:

@Composable
fun Screen() {                    // Group P (address 100)
    Column {                      // Group A (address 200)  
        Text("Hello")             // Group B (address 300)
        Button(onClick = {})      // Group C (address 450)
    }
}

在 groups 数组中的存储可能类似:

groups[100..105] = P: [key=123, next=-1, parent=-1, child=200, ...]
groups[200..205] = A: [key=456, next=-1, parent=100, child=300, ...]
groups[300..305] = B: [key=789, next=450, parent=200, child=-1, ...]
groups[450..455] = C: [key=999, next=-1, parent=200, child=-1, ...]

树的遍历(深度优先):

  1. 从 P 开始 → child(P) = 200,进入 A
  2. 从 A 开始 → child(A) = 300,进入 B
  3. 从 B 开始 → child(B) = -1,无子节点;next(B) = 450,进入 C
  4. 从 C 开始 → child(C) = -1,next(C) = -1,回溯

但既然物理位置无关紧要,新的 Group 从哪里分配呢?答案是一套类似操作系统内存管理的 Bump Allocation + Free List 策略。

Group 0:特殊的元数据头

Group 0(地址 0)是一个被保留的特殊节点,它不存储任何用户数据,而是作为分配器的元数据头:

  • groupChild(0) — 线性分配的水位线(bump pointer),指向下一个未使用的空间
  • groupNext(0) — 空闲链表的头指针,指向第一个被回收的 Group

分配器的两阶段策略

阶段 1:Bump Allocation (线性分配)
groups 数组:[G0] [G1] [G2] [G3] [空] [空] [空] ...
                                  ↑
                         groupChild(0) = 24 (水位线)
快速分配:每次直接从水位线取 6 个 Int,水位线 +6

阶段 2:Free List Recycling (空闲链表复用)
当水位线到达数组末尾,且有被删除的 Group 时:
groups 数组:[G0] [G1] [已删] [G3] [已删] [G5] ...
                      ↑              ↑
                      |←---next------|
                      |
              groupNext(0) 指向第一个已删除的 Group
从空闲链表取节点:O(1) 的头删除操作

让我们看看源码吧

// composer/linkbuffer/SlotTableAddressSpace.kt
private fun IntArray?.groupAllocate(
    key: Int,              // 要分配的 Group 的 Key
    parent: GroupAddress,  // 父节点地址
    flags: GroupFlags,     // 标志位(IsNode/HasObjectKey 等)
): GroupAddress {
    // 安全检查:数组必须至少有 6 个 Int
    if (this == null || size < SLOT_TABLE_GROUP_SIZE) return -1

    // ========== 分配策略选择 ==========
    val address = groupChild(0).let { watermark ->  // watermark = 当前水位线
        if (watermark >= size) {
            // 策略 B:线性区域已满,尝试从空闲链表取
            val nextFree = groupNext(0)  // 获取空闲链表头
            if (nextFree < 0) return -1  // 空闲链表也空了,需要扩容(外部处理)
            
            // 从空闲链表摘下头节点(单链表头删除):
            // 1. 读取被摘节点的 next 指针
            // 2. 将 groupNext(0) 指向它(跳过当前头节点)
            groupNext(0, groupNext(nextFree))  // 等价于:this[1] = this[nextFree + 1]
            nextFree  // 返回被摘节点的地址
        } else {
            // 策略 A:Bump allocation(快速路径)
            // 从水位线分配 6 个 Int,水位线向前移动
            groupChild(0, watermark + SLOT_TABLE_GROUP_SIZE)  // 水位线 +6
            watermark  // 返回当前水位线位置
        }
    }

    // ========== 初始化新 Group 的 6 个字段 ==========
    groupKey(address, key)                // this[address + 0] = key
    groupParent(address, parent)          // this[address + 2] = parent
    groupNext(address, NULL_ADDRESS)      // this[address + 1] = -1 (无兄弟)
    groupChild(address, NULL_ADDRESS)     // this[address + 3] = -1 (无孩子)
    groupFlags(address, flags)            // this[address + 4] = flags
    groupSlotRange(address, NULL_ADDRESS) // this[address + 5] = -1 (无数据)
    
    return address  // 返回新分配的地址
}

3. O(1) 移动:指针手术

现在,让我们回到那个让 Gap Buffer 崩溃的场景:移动 Group

假设我们要交换 A 和 B 的顺序,从 P -> A -> B 变成 P -> B -> A。 在 Link Buffer 中,这只需要修改几个整数(指针):

操作前:

      [ P ]
        | child
        v
      [ A ] --next--> [ B ] --next--> NULL
      
物理内存:
groups[100] = P {child: 200, ...}
groups[200] = A {next: 300, parent: 100, ...}
groups[300] = B {next: -1,  parent: 100, ...}

操作步骤(SlotTableEditor.moveGroup):

目标:把 A 移到 B 后面

第 1 步:断开 A(修改 P 的 child 指针)
      [ P ]
        | child (原本指向 A,改为指向 B)
        v
  断开→[ A ]   [ B ] --next--> NULL
        
操作:P.child = A.next  // groups[100+3] = groups[200+1]

第 2 步:安放 A 到 B 之后
      [ P ]
        | child
        v
      [ B ] --next--> [ A ] --next--> NULL

操作:
  A.next = B.next     // groups[200+1] = groups[300+1] (A 指向 NULL)
  B.next = A          // groups[300+1] = 200 (B 指向 A)

操作后:

      [ P ]
        | child
        v
      [ B ] --next--> [ A ] --next--> NULL

物理内存(注意:地址没变!):
groups[100] = P {child: 300, ...}  // 只改了一个字段
groups[200] = A {next: -1, parent: 100, ...}  // 只改了一个字段
groups[300] = B {next: 200, parent: 100, ...}  // 只改了一个字段

复杂度分析:

  • 内存拷贝:0 字节(A 和 B 的物理位置完全没变)
  • 指针修改:3 个整数(P.child、A.next、B.next)
  • 子树影响:无论 A 和 B 下面挂着多少子节点(哪怕是一整棵包含 1000 个节点的复杂 UI 子树),我们只修改了父层的 3 个整数。这就是 O(1)

对比 Gap Buffer

  • Gap Buffer:需要拷贝 A 及其所有子节点的内存(可能数百个 Group)
  • Link Buffer:只修改 3 个整数(12 字节)

对应到如下源码:

// composer/linkbuffer/SlotTableEditor.kt
fun moveGroup(offset: Int) {
    // offset:要移动的目标相对于 current 的偏移量
    // 例如:offset=2 表示"把 current 的第 2 个兄弟移到 current 前面"
    
    if (offset == 0) return  // 移动到自己位置,无操作
    
    var source = current           // source:要移动的节点
    var previousSource = previousSibling  // previousSource:source 的前驱
    val groups = addressSpace.groups
    
    // ========== 第 1 步:定位目标节点 ==========
    // 沿 next 链走 offset 步,找到要移动的 source
    repeat(offset) {
        previousSource = source
        source = groups.groupNext(source)  // source = source.next
    }
    
    // 此时:previousSource → source → sourceNext
    //      我们要把 source 移到 current 前面
    
    // ========== 第 2 步:从原位置断开 source ==========
    val sourceNext = groups.groupNext(source)  // 保存 source 的下一个节点
    groups.groupNext(previousSource, sourceNext)  
    // 效果:previousSource.next = sourceNext (跳过 source)
    
    // ========== 第 3 步:将 source 插入到 current 前面 ==========
    groups.groupNext(source, current)  
    // 效果:source.next = current (source 现在指向 current)
    
    if (previousSibling == NULL_ADDRESS) {
        // current 原本是父节点的第一个孩子,现在 source 成为第一个孩子
        groups.groupChild(parent, source)  // parent.child = source
    } else {
        // current 原本不是第一个孩子,source 插入到 previousSibling 之后
        groups.groupNext(previousSibling, source)  // previousSibling.next = source
    }
    
    this.current = source  // 更新 current 指针到新插入的节点
}

再来看个例子:假设我们有 [A, B, C, D] 四个兄弟,current 指向 B,调用 moveGroup(2)

初始状态:
  A → B → C → D → NULL
      ↑
    current

moveGroup(2) 的执行过程:

1. 定位:offset=2,从 B 往后走 2 步 → source=D, previousSource=C
  A → B → C → D → NULL
              ↑   ↑
      previousSource source

2. 断开 D:C.next = D.next (NULL)
  A → B → C → NULL    D (孤立)

3. 插入 D 到 B 前面:
   - D.next = B
   - A.next = D
  A → D → B → C → NULL
      ↑
    current (更新为 D)

最终结果:[A, D, B, C]

删除操作同样简单

fun removeGroup(freeGroup: Boolean = true) {
    val groups = addressSpace.groups
    val next = groups.groupNext(current)  // 获取 current 的下一个兄弟
    
    // ========== 从链表中摘除 current ==========
    if (previousSibling == NULL_ADDRESS) {
        // current 是第一个孩子
        if (parent == NULL_ADDRESS) {
            table.root = next  // current 是根节点
        } else {
            groups.groupChild(parent, next)  // parent.child = next(跳过 current)
        }
    } else {
        // current 不是第一个孩子
        groups.groupNext(previousSibling, next)  // previousSibling.next = next(跳过 current)
    }
    
    // ========== 回收 current 的空间 ==========
    if (freeGroup) {
        addressSpace.freeGroupTree(current)  // 递归将 current 及其子树归还到空闲链表
    }
    
    this.current = next  // 移动到下一个节点
}

删除示例

初始:A → B → C → NULL (current=B, previousSibling=A)

执行 removeGroup():
  1. next = C
  2. A.next = C (跳过 B)
  3. freeGroupTree(B) (B 挂入空闲链表)
  4. current = C

结果:A → C → NULL, B 被回收到空闲链表

进阶机制:GroupHandle 与"我也许知道"

在链表操作中,最麻烦的是 "找前驱"(Find Predecessor)。 要删除节点 B,你必须找到指向 B 的那个人(可能是节点 A,也可能是父节点 P,单向链表本身不记录"谁指向了我")。

Compose 引入了 GroupHandle 来优化这个问题。它本质是一个 Long,将两个 GroupAddress(各为 32 位 Int)打包在一起:

// composer/linkbuffer/GroupHandle.kt

// ============ 核心设计 ============
// GroupHandle 本质是一个 Long(64 位),通过位操作打包两个信息:
// • 高 32 位:context(可能的前驱节点或父节点)
// • 低 32 位:group(目标节点)
internal typealias GroupHandle = Long

internal const val NULL_GROUP_HANDLE: GroupHandle = -1
internal const val LAZY_ADDRESS = 0  // 哨兵值:表示"前驱未知,需要时再扫描"

// ============ 打包/解包操作 ============
// 打包:将两个 Int (各 32 位) 压入一个 Long (64 位)
internal inline fun makeGroupHandle(
    groupContext: GroupAddress,  // 前驱/父节点(高 32 位)
    group: GroupAddress          // 目标节点(低 32 位)
): GroupHandle =
    (groupContext.toLong() shl Int.SIZE_BITS) or group.toUInt().toLong()
    //                     ↑                      ↑
    //           左移 32 位(放到高位)        保留低 32 位

// 解包:提取高 32 位(context)
internal val GroupHandle.context
    get() = (this ushr Int.SIZE_BITS).toInt()  // 无符号右移 32 位

// 解包:提取低 32 位(group)
internal val GroupHandle.group
    get() = toInt()  // 直接截取低 32 位

// ============ 三参数智能构造器 ============
// 根据 group 是否有效,自动选择 context 的语义
internal fun makeGroupHandle(
    parent: GroupAddress,       // 父节点
    predecessor: GroupAddress,  // 前驱节点
    group: GroupAddress,        // 目标节点
): GroupHandle =
    if (group >= 0) {
        // group 有效:context 是前驱节点(用于 O(1) 的链表操作)
        makeGroupHandle(groupContext = predecessor, group = group)
    } else {
        // group 无效(-1):context 退化为 parent(表示"在 parent 的子列表末尾")
        makeGroupHandle(groupContext = parent, group = NULL_ADDRESS)
    }

为什么需要 context?再举个例子

场景:要删除节点 C

初始链表:A → B → C → D → NULL

删除 C 的两个关键步骤:
  1. 找到 C 的前驱(B)
  2. 修改 B.next = D (跳过 C)

问题:单向链表中,从 C 无法直接找到 B
解决:GroupHandle 将 B 的地址(context)和 C 的地址(group)打包在一起

handle = makeGroupHandle(context: B, group: C)
       = 0x00000064_000000C8  (假设 B=100, C=200)
           ↑高32位     ↑低32位

删除时:
  predecessor = handle.context  // 提取 B
  target = handle.group         // 提取 C
  groups.groupNext(predecessor, groups.groupNext(target))  // B.next = C.next

但为什么说 context 只是"也许"知道?

因为树是动态的。当你拿到一个 Handle 后,树可能已经变了:

时刻 T1:获取 handle
  A → B → C → D
  handle = makeGroupHandle(context: B, group: C)

时刻 T2:其他代码插入了 X
  A → X → B → C → D
      ↑
    新插入的节点

时刻 T3:使用 handle
  问题:handle.context 仍然是 B,但 B 现在不是 C 的前驱了(X 才是)
  解决:验证 handle.context,如果失效就重新扫描

乐观验证策略

来看 moveGroup(handle) 中的验证逻辑——这正是乐观策略的核心:

// composer/linkbuffer/SlotTableEditor.kt
fun moveGroup(handle: GroupHandle) {
    val source = handle.group        // 提取目标节点
    var previousSource = handle.context  // 提取"可能的"前驱
    val groups = addressSpace.groups
    val parent = parent
    
    // ========== 乐观验证:context 是否仍然有效? ==========
    // 检查两种情况:
    // 1. context 是父节点(previousSource == NULL):验证 parent.child == source
    // 2. context 是前驱节点:验证 previousSource.next == source
    if (
        (previousSource == NULL_ADDRESS && groups.groupChild(parent) != source) ||
        (previousSource != NULL_ADDRESS && groups.groupNext(previousSource) != source)
    ) {
        // ========== 验证失败:context 已过期 ==========
        // 从 current 开始线性扫描,找到真正的前驱
        previousSource = current
        while (previousSource != NULL_ADDRESS && 
               groups.groupNext(previousSource) != source) {
            previousSource = groups.groupNext(previousSource)
        }
        // 最坏情况:扫描整个兄弟列表(O(SiblingCount))
    }
    
    // ========== 找到有效前驱后,执行标准的链表操作 ==========
    val sourceNext = groups.groupNext(source)
    
    // 从原位置断开 source
    groups.groupNext(previousSource, sourceNext)  // previousSource.next = source.next
    
    // 插入 source 到 current 前面
    groups.groupNext(source, current)  // source.next = current
    
    if (previousSibling == NULL_ADDRESS) {
        // current 原本是第一个孩子,source 成为新的第一个孩子
        groups.groupChild(parent, source)
    } else {
        // source 插入到 previousSibling 之后
        groups.groupNext(previousSibling, source)
    }
    
    this.current = source
}

性能分析

场景 A(极速路径):顺序遍历
  初始:current → A → B → C
  操作:moveGroup(handleC),其中 handleC.context = B
  验证:B.next == C ✓(命中!)
  复杂度:O(1),直接使用 context,无需扫描

场景 B(兜底路径):随机操作或 context 过期
  初始:current → A → X → B → C (X 是后插入的)
  操作:moveGroup(handleC),其中 handleC.context = B (但现在 B.next 不是 C 了)
  验证:B.next == C ✗(失效!)
  兜底:从 current 开始扫描 → A → X → B → C(找到了!)
  复杂度:O(SiblingCount),最坏情况扫描整个兄弟列表

场景 C:LAZY_ADDRESS(显式标记为"不知道")
  handle = makeGroupHandle(context: LAZY_ADDRESS, group: C)
  验证:LAZY_ADDRESS != NULL ✓ 且 LAZY_ADDRESS.next != C ✗(故意失效)
  兜底:从 current 开始扫描
  用途:当创建 handle 时就不知道前驱,显式标记为延迟查找

这种设计体现了 "Optimistic Concurrency" 的思想:

  1. 大多数情况下(顺序遍历、局部修改),context 有效,享受 O(1) 极速
  2. 极少数情况(随机操作、过期 handle),context 失效,退化为 O(N) 扫描
  3. 整体收益:大多数的 O(1) + 少数的 O(N) >> 100% 的 O(N)(单向链表)

对比双向链表,它虽然始终 O(1),但每个节点需要多存一个 prev 指针(6 -> 7 个字段,+16.7% 内存),而且维护起来更复杂

Compose 选择了后者,因为:

  • 内存敏感:Slot Table 可能有成千上万个 Group,16.7% 的内存开销非常可观
  • 访问模式:Compose 的遍历高度顺序化(深度优先遍历 UI 树),随机跳转极少

SlotRange:数据去哪了?

最后,我们来看看实际的数据(Slots)。在 Link Buffer 中,Group 只存储树的结构信息(指针),而真正的数据(remembered values)存储在独立的 slots: Array<Any?> 数组中。Group 通过 SlotRange 字段来引用自己的数据。

1. 位压缩:一个 Int 装下地址和大小

SlotRange 是一个 Int(32 位),通过位操作同时编码了两个信息:

SlotRange 的位布局(32 位):
┌─────────────────────────────┬─────────┐
│   高 28 位:address          │ 低 4 位 │
│   (slots 数组的起始位置)      │   size  │
└─────────────────────────────┴─────────┘

为什么这样设计?

  • 地址(28 位):支持 2^28 = 268,435,456 个 slot,远超实际需求
  • 大小(4 位):只能表示 0-15,但这已经覆盖了绝大多数的情况,即使超了也有解决办法(下文)
  • 节省内存:用 1 个 Int 代替 2 个 Int(address + size),省 50% 空间

老规矩,来看代码:

// composer/linkbuffer/SlotTableAddresSpace.kt

// ============ 常量定义 ============
internal const val SLOT_TABLE_SLOT_SHIFT = 4          // 地址左移 4 位(为 size 腾出空间)
private const val SLOT_TABLE_SLOT_SMALL_SIZE_MASK = 0xF  // 0b1111,用于提取低 4 位
private const val SLOT_TABLE_SLOT_LARGE_SENTINEL = 0xF   // 哨兵值:0xF 表示"大对象"
internal const val SLOT_TABLE_SLOT_MAX_SMALL_SIZE = 15  // 小对象的最大大小

// ============ 解包操作 ============
// 提取地址:右移 4 位,去掉 size 部分
internal inline fun slotAddressOf(slotRange: SlotRange): SlotAddress =
    slotRange shr SLOT_TABLE_SLOT_SHIFT
    // 例如:0x00001234 (十六进制) → 右移 4 位 → 0x00000123

// 提取小对象的大小:取低 4 位,然后 +1
internal inline fun slotSmallSizeOf(slotRange: SlotRange): Int =
    (slotRange and SLOT_TABLE_SLOT_SMALL_SIZE_MASK) + 1
    // 为什么 +1?因为 size=0 用 NULL_ADDRESS (-1) 表示,
    // 所以 size=1 存为 0,size=2 存为 1,...,size=15 存为 14

// ============ 打包操作 ============
// 将 address 和 size 压缩成一个 Int
internal fun slotRangeFromAddressAndSize(address: SlotAddress, size: Int): SlotRange =
    (address shl SLOT_TABLE_SLOT_SHIFT) or  // address 左移 4 位(腾出低 4 位)
        if (size > SLOT_TABLE_SLOT_MAX_SMALL_SIZE) {
            SLOT_TABLE_SLOT_LARGE_SENTINEL  // 大对象:低 4 位标记为 0xF
        } else {
            size - 1  // 小对象:存 size - 1(因为读取时会 +1)
        }

// ============ 统一的大小读取(处理大/小对象分支)============
inline fun SlotTableAddressSpace.slotSize(slotRange: SlotRange): Int {
    if (slotRange == NULL_ADDRESS) return 0  // 空范围(Group 没有数据)
    
    val smallSize = slotSmallSizeOf(slotRange)  // 先尝试读取低 4 位
    
    return if (isLargeSlotRangeSize(smallSize)) {
        // 如果低 4 位是 0xF(哨兵),说明是大对象
        // 从辅助 Map 中查找真实大小
        largeSizes[slotAddressOf(slotRange)] // largeSizes : mutableIntIntMapOf,通过两个 IntArray 分别存储 key 和 value
    } else {
        // 否则,低 4 位就是真实大小
        smallSize
    }
}

// 判断是否为大对象
internal inline fun isLargeSlotRangeSize(size: Int) = size > SLOT_TABLE_SLOT_MAX_SMALL_SIZE

看点例子

// ========== 场景 1:小对象(size=3)==========
val address = 100  // slots[100] 是起始位置
val size = 3       // 占用 3 个 slot

// 打包
val range = slotRangeFromAddressAndSize(address, size)
//   = (100 << 4) | (3 - 1)
//   = 1600 | 2
//   = 1602
//   = 0x00000642 (十六进制)
//   = 0b00000000_00000000_00000110_01000010 (二进制)
//       ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^
//               address=100       size-1=2

// 解包
val decodedAddress = slotAddressOf(range)    // 1602 >> 4 = 100 ✓
val decodedSize = slotSmallSizeOf(range)     // (1602 & 0xF) + 1 = 2 + 1 = 3 ✓

// 实际使用
slots[decodedAddress + 0] = "Hello"   // slots[100]
slots[decodedAddress + 1] = 42        // slots[101]
slots[decodedAddress + 2] = true      // slots[102]

// ========== 场景 2:大对象(size=100)==========
val largeAddress = 500
val largeSize = 100  // 超过 15,需要辅助 Map

// 打包(大对象标记)
val largeRange = slotRangeFromAddressAndSize(largeAddress, largeSize)
//   = (500 << 4) | 0xF
//   = 8000 | 15
//   = 8015
//   = 0x00001F4F (十六进制)
//       ^^^^^ ^^
//       500   哨兵 0xF

// 同时将真实大小存入辅助 Map
largeSizes[500] = 100

// 解包(检测到哨兵,查 Map)
val largeSizeDecoded = slotSize(largeRange)
//   smallSize = (8015 & 0xF) + 1 = 15 + 1 = 16
//   isLargeSlotRangeSize(16)? Yes!
//   return largeSizes[500] = 100 ✓

// ========== 场景 3:空对象(无数据)==========
val emptyRange = NULL_ADDRESS  // -1
val emptySize = slotSize(emptyRange)  // 0

2. Bump Allocation:快速分配

Slot 的分配使用 Bump Allocation(碰撞指针分配),简单高效。它维护两个指针,将 slots 数组分为三个区域:

slots 数组的内存布局:
┌──────────────┬─────────────────┬──────────────┐
│  已分配区域    │  未分配区域       │  已释放区域   │
│  (活跃数据)    │  (可用空间)      │  (碎片/死区)  │
└──────────────┴─────────────────┴──────────────┘
0     unallocatedStart  unallocatedEnd        size
               ↑                ↑
            水位线            边界指针

分配策略(优先级递减)

// SlotTableAddressSpace.kt
private var unallocatedStart = 0         // 未分配区域的起点(水位线)
private var unallocatedEnd = slots.size  // 未分配区域的终点
private var freeSlotCount = 0            // 已释放但未回收的 slot 数量

private fun allocateSlots(size: Int): Int {
    val unallocatedStart = unallocatedStart
    val unallocatedEnd = unallocatedEnd
    
    // ========== 策略 1:Bump Allocation(快速路径)==========
    if (unallocatedStart + size <= unallocatedEnd) {
        val newAddress = unallocatedStart
        this.unallocatedStart = newAddress + size  // 水位线移动
        
        // 如果是大对象,记录到辅助 Map
        if (isLargeSlotRangeSize(size)) {
            largeSizes[newAddress] = size
        }
        
        // 初始化为 Composer.Empty(标记为已分配但未写入)
        slots.fill(Composer.Empty, newAddress, newAddress + size)
        
        return slotRangeFromAddressAndSize(newAddress, size)
    } 
    
    // ========== 策略 2:空间不足,触发压缩 ==========
    else {
        compactAndMaybeGrow(size)  // 清理碎片 + 可能扩容
        
        // 压缩后重新尝试分配
        val newUnallocatedStart = this.unallocatedStart
        val newUnallocatedEnd = this.unallocatedEnd
        
        if (newUnallocatedStart + size <= newUnallocatedEnd) {
            val newAddress = newUnallocatedStart
            this.unallocatedStart = newAddress + size
            if (isLargeSlotRangeSize(size)) {
                largeSizes[newAddress] = size
            }
            slots.fill(Composer.Empty, newAddress, newAddress + size)
            return slotRangeFromAddressAndSize(newAddress, size)
        }
        
        // 理论上不会到这里(compactAndMaybeGrow 会保证空间足够)
        composeRuntimeError("compactAndMaybeGrow did not grow enough")
    }
}

举个栗子

初始状态(空数组):
slots: [Unallocated, Unallocated, Unallocated, Unallocated, ...]
        ↑ unallocatedStart=0              unallocatedEnd=1024 ↑

操作 1:allocateSlots(3)
  → newAddress = 0
  → unallocatedStart = 3
slots: [Empty, Empty, Empty, Unallocated, Unallocated, ...]
        ^^^^^^^^^^^^^^^^ 分配给 Group A
                              ↑ unallocatedStart=3

操作 2:allocateSlots(2)
  → newAddress = 3
  → unallocatedStart = 5
slots: [Empty, Empty, Empty, Empty, Empty, Unallocated, ...]
        ^Group A^     ^     Group B    ^
                                            ↑ unallocatedStart=5

操作 3:写入数据
groups.groupSlotRange(groupA, slotRangeFromAddressAndSize(0, 3))
slots[0] = "Hello"
slots[1] = 42
slots[2] = true

groups.groupSlotRange(groupB, slotRangeFromAddressAndSize(3, 2))
slots[3] = "World"
slots[4] = false

复杂度分析

  • Bump Allocation:O(1),只需移动水位线
  • 初始化fill):O(size),但这是必需的
  • 总体:O(size),且常数极小(只是数组赋值)

3. 原地增长:避免不必要的拷贝

当一个 Group 需要更多 slot 时(例如:remember 了新的 state),有两种策略:

策略 A:原地增长(In-place Growth)

// SlotTableAddressSpace.kt
private fun growSlotRangeAtGroup(group: GroupAddress, currentSize: Int, newSize: Int): Int {
    val range = groups.groupSlotRange(group)
    val address = slotAddressOf(range)
    
    // ========== 特例 1:恰好在未分配区域前(Building Time)==========
    if (address + currentSize == unallocatedStart) {
        // Group 的 slot 恰好是最后分配的,可以直接扩展
        if (address + newSize <= unallocatedEnd) {
            this.unallocatedStart += newSize - currentSize  // 水位线移动
            if (newSize > SLOT_TABLE_SLOT_MAX_SMALL_SIZE) {
                largeSizes[address] = newSize
            }
            val newRange = slotRangeFromAddressAndSize(address, newSize)
            slots.clearRange(address + currentSize, address + newSize)
            groups.groupSlotRange(group, newRange)
            return newRange  // O(1) 完成!
        }
    }
    
    // ========== 特例 2:后面的 slot 都是 Unallocated(碎片复用)==========
    val needed = newSize - currentSize
    if (slots.allUnallocated(address + currentSize, needed)) {
        // 后面恰好有足够的死区空间,直接占用
        if (newSize > SLOT_TABLE_SLOT_MAX_SMALL_SIZE) {
            largeSizes[address] = newSize
        }
        val newRange = slotRangeFromAddressAndSize(address, newSize)
        slots.clearRange(address + currentSize, address + newSize)
        groups.groupSlotRange(group, newRange)
        freeSlotCount -= needed  // 减少碎片计数
        return newRange  // O(1) 完成!
    }
    
    // ========== 策略 B:无法原地增长,分配新空间 + 拷贝 ==========
    // 分配时额外预留 8 个 slot(SLOT_TABLE_SLOT_MOVE_BUFFER_SIZE)
    // 下次再增长时,可能触发上面的特例 2
    val bufferedSize = newSize + SLOT_TABLE_SLOT_MOVE_BUFFER_SIZE
    val bufferedRange = allocateSlots(bufferedSize)
    val newRange = shrinkSlotRange(bufferedRange, bufferedSize, newSize)
    val newAddress = slotAddressOf(newRange)
    
    // 拷贝旧数据
    val currentRange = groups.groupSlotRange(group)
    val currentAddress = slotAddressOf(currentRange)
    if (newAddress != currentAddress) {
        slots.copyInto(
            destination = slots,
            destinationOffset = newAddress,
            startIndex = currentAddress,
            endIndex = currentAddress + currentSize,
        )
        freeSlotsAt(currentAddress, currentSize)  // 标记旧空间为死区
    }
    groups.groupSlotRange(group, newRange)
    return newRange
}

// 检查一段连续的 slot 是否都是 Unallocated(死区)
private inline fun Array<Any?>.allUnallocated(start: Int, size: Int): Boolean {
    val end = start + size
    if (end >= this.size) return false
    for (i in start until end) {
        if (this[i] !== Unallocated) return false
    }
    return true
}

增长示例

初始状态:Group A 占用 slots[10..12](3 个 slot)
slots: [... 10:A0, 11:A1, 12:A2, 13:Unallocated, 14:Unallocated, ...]

操作:Group A 增长到 5 个 slot

检查 slots[13..14] 是否都是 Unallocated?
  → Yes! 原地增长
  
结果:
slots: [... 10:A0, 11:A1, 12:A2, 13:Empty, 14:Empty, ...]
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Group A (5 slots)

如果 slots[13] 已经被占用:
  → 无法原地增长
  → 分配新空间(例如 slots[50..54])
  → 拷贝 A0, A1, A2 到 slots[50..52]
  → 标记 slots[10..12] 为 Unallocated(碎片)

4. Compaction:垃圾回收时刻

当碎片过多或空间不足时,触发 Compaction(压缩):

// SlotTableAddressSpace.kt
private fun compactAndMaybeGrow(required: Int) {
    val slots = slots
    val currentSize = slots.size
    val unallocatedSize = unallocatedEnd - unallocatedStart
    
    // ========== 计算实际使用量 ==========
    val spaceUsed = slots.size - (unallocatedSize + freeSlotCount)
    val spaceNeeded = spaceUsed + required
    
    // ========== 计算新容量(2 的幂,+3% 预留)==========
    val adjustedSpace = spaceNeeded + (slots.size shr 5)  // +3.125% * size
    val newSize = (1 shl (32 - adjustedSpace.countLeadingZeroBits())).let {
        if (it < currentSize) currentSize else it  // 至少保持原大小
    }
    
    val newSlots = if (newSize != currentSize) {
        Array<Any?>(newSize) { Composer.Empty }  // 扩容
    } else {
        slots  // 原地压缩
    }
    
    val newLargeSizes = mutableIntIntMapOf()
    var current = 0  // 新数组的写入位置
    val groupsEnd = groups.groupChild(0)  // 遍历所有已分配的 Group
    
    // ========== 遍历所有 Group,拷贝活跃数据 ==========
    val mover = SlotMoveManager(source = slots, destination = newSlots)
    for (index in SLOT_TABLE_GROUP_SIZE..groupsEnd - 1 step SLOT_TABLE_GROUP_SIZE) {
        val slotRange = groups.groupSlotRange(index)
        if (slotRange != NULL_ADDRESS) {
            slotAddressAndSize(slotRange) { address, size ->
                // 拷贝 slots[address..address+size) 到 newSlots[current..)
                mover.move(
                    destinationOffset = current,
                    startIndex = address,
                    endIndex = address + size,
                )
                
                // 更新大对象记录
                if (isLargeSlotRangeSize(size)) {
                    newLargeSizes[current] = size
                }
                
                // 更新 Group 的 SlotRange(指向新位置)
                groups.groupSlotRange(index, slotRangeFromAddressAndSize(current, size))
                current += size
            }
        }
    }
    
    // ========== 更新全局状态 ==========
    this.slots = mover.done()
    this._largeSizes = newLargeSizes.takeIf { it.isNotEmpty() }
    this.unallocatedStart = current       // 新的水位线
    this.unallocatedEnd = newSlots.size   // 新的边界
    this.freeSlotCount = 0                // 碎片清零
}

压缩示例

压缩前(碎片化):
slots: [A0, A1, ✗, ✗, B0, B1, B2, ✗, ✗, ✗, C0, Unallocated, ...]
        ^^^^    死区  ^^^^^^^    死区      ^^
        Group A      Group B            Group C
spaceUsed = 6 (A:2 + B:3 + C:1)
freeSlotCount = 5 (死区)

压缩后(紧凑):
slots: [A0, A1, B0, B1, B2, C0, Unallocated, Unallocated, ...]
        ^^^^    ^^^^^^^    ^^   ↑ unallocatedStart=6
        Group A Group B    C
        
更新 SlotRange:
  groupA.slotRange: (old) 0→0 (new) 0→0 ✓(没变)
  groupB.slotRange: (old) 4→4 (new) 2→2 ✓(前移 2 位)
  groupC.slotRange: (old) 10→A (new) 5→5 ✓(前移 5 位)

触发时机

  • allocateSlots 发现空间不足时(主动触发)
  • 碎片率过高时(例如:freeSlotCount > slots.size * 0.3

复杂度:O(ActiveSlots),只拷贝活跃数据,忽略死区

5. 总结:分层设计

Link Buffer 的 Slot 管理体现了 "快慢分离" 的设计哲学:

快速路径

  • 分配:Bump Allocation,O(1)
  • 原地增长:检测后面是否有死区,O(1) 复用
  • 读写slots[slotAddressOf(range) + offset],O(1)

慢速路径

  • ⚙️ 拷贝 + 分配:无法原地增长时,O(size)
  • ⚙️ 压缩:碎片清理,O(ActiveSlots)
  • ⚙️ 扩容:数组增长,O(newSize)

这样的设计保证了:

  1. 结构和数据分离:Group(树结构)和 Slot(数据)独立管理,各自优化
  2. 延迟回收:删除 Group 时不立即整理 Slot,批量压缩摊销开销
  3. 位压缩:大多数小对象零额外开销(只放到低 4 位中),少量大对象用 Map 兜底( Map 还是特别优化的稀疏 Map,底层也是 Arra)
  4. 预留 Buffer:增长时多分配 8 个 slot,为下次增长创造原地机会

对比 Gap Buffer

维度Gap BufferLink Buffer (Slot)
分配O(N) 移动 GapO(1) Bump Allocation
增长O(N) 移动整个子树O(1) 原地增长(大多数情况)
删除O(N) 移动 GapO(1) 标记为死区
碎片整理无(通过 Gap 避免)O(ActiveSlots)(低频触发)

最终,Link Buffer 用 "空间换时间 + 批量处理" 的策略,将 Slot 操作的平均复杂度降至接近 O(1)。


R8 优化与上线策略:如何开启新世界

既然 Link Buffer 这么强,我们要怎么用上它呢? Compose 团队采用了一种非常稳健的渐进式发布策略

1. 实验性开关

ComposeRuntimeFlags.kt 中,有一个实验性开关:

// ComposeRuntimeFlags.kt
@ExperimentalComposeApi
public object ComposeRuntimeFlags {
    // 必须在第一次 setContent 之前设置,之后不可更改
    // R8 release 构建中,proguard 规则会覆盖此值
    @JvmField
    public var isLinkBufferComposerEnabled: Boolean = false
}

如果你想在你的 App 里尝鲜,或者进行性能对比测试,你可以在第一次调用 setContent 之前手动开启它:

// 在 Application 初始化或 Activity onCreate 最早处
ComposeRuntimeFlags.isLinkBufferComposerEnabled = true

注意:一旦 Runtime 启动,就不支持动态切换了。因为内存中的数据结构已经定型,不能混用。

2. R8 的魔法:Release 包的极致瘦身

Google 并不希望你的 Release 包里背着两套完整的 SlotTable 实现(GapBuffer 版和 LinkBuffer 版)。这会增加包体积,也会干扰 R8 的去虚拟化(Devirtualization)优化。

因此,官方推荐利用 R8 的 -assumevalues 规则来"锁死"这个开关(默认的混淆规则目前会把它强制设为 false)。

如果你决定在生产环境启用 Link Buffer,可以在 proguard-rules.pro 中添加:

# 强制开启 LinkBuffer,并告诉 R8 它是常量
-assumevalues public class androidx.compose.runtime.ComposeRuntimeFlags {
    static boolean isLinkBufferComposerEnabled return true;
}

这会使得:

  1. 运行时行为:确保 isLinkBufferComposerEnabled 永远返回 true
  2. 死代码消除:R8 看到这个值是恒为真的常量后,会极其聪明地把所有 if (false) { ... } (即 GapBuffer 的旧代码)全部删掉!

总结

从 Gap Buffer 到 Link Buffer 的迁移,是 Compose Runtime 为了适应高度动态 UI 而做出的选择。

  • Gap Buffer:假设"位置即身份",适合顺序编辑,但在随机重排时遭遇 O(N) 瓶颈。
  • Link Buffer:拥抱"指针即结构",将物理存储与逻辑拓扑分离。

通过 6-Int Group 结构GroupHandle 的乐观导航以及 SlotRange 的延迟整理,Compose 成功地将最昂贵的 UI 操作(列表重排、条件内容移动)的时间复杂度从线性的 O(N) 降维到了常数级的 O(1)。不得不说非常 Amazing 啊。底层的各种位压缩相信也是看的大家一愣一愣的。那么,你会开启试试吗?