什么?Compose 把 GapBuffer 换成了 LinkBuffer?

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

本文参考自 compose-slottable-gap-to-link 公开部分,基于 Jetpack Compose 1.11.0-alpha06 源码,随代码更新部分内容可能有变。编写过程中由我(Fish) 和 GPT/Gemini/Claude 共同完成
本文内容较为底层,读者可以选择看个乐呵,或者看看里面位压缩的技巧,或许能有所启发

速览:什么是 GapBuffer/SlotTable/Composition

请看下面的代码:

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

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

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

kotlin 复制代码
// 注意:这是"长得像"的伪代码,省略了大量细节(参数、标记位、内联、稳定性推断等)。
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/

kotlin 复制代码
// 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 字段,它会在数组变化时自动更新:

kotlin 复制代码
// 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),它们紧密排列成一个"虚拟结构体":

kotlin 复制代码
// 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 的位域存储,布局如下:

python 复制代码
// 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 步操作的沉重:

kotlin 复制代码
// 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 就诞生了。


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

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

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

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

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

kotlin 复制代码
// 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 数组,它长这样:

text 复制代码
Index:  0  1  2  3  4  5    6  7  8  9 10 11   ...
       [ Group 0 Data   ]  [ Group 1 Data   ]  ...

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

kotlin 复制代码
// ============ 类型定义 ============
// 类型别名------本质都是 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 开始:

kotlin 复制代码
// 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。它们在数组里可能是乱序存放的:

text 复制代码
物理内存布局 (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)

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

text 复制代码
        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 中渲染一个简单的界面:

kotlin 复制代码
@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 数组中的存储可能类似:

text 复制代码
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

分配器的两阶段策略

text 复制代码
阶段 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) 的头删除操作

让我们看看源码吧

kotlin 复制代码
// 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 中,这只需要修改几个整数(指针):

操作前:

text 复制代码
      [ 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):

text 复制代码
目标:把 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)

操作后:

text 复制代码
      [ 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 字节)

对应到如下源码:

kotlin 复制代码
// 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)

text 复制代码
初始状态:
  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]

删除操作同样简单

kotlin 复制代码
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  // 移动到下一个节点
}

删除示例

text 复制代码
初始: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)打包在一起:

kotlin 复制代码
// 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?再举个例子

kotlin 复制代码
场景:要删除节点 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 后,树可能已经变了:

kotlin 复制代码
时刻 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) 中的验证逻辑------这正是乐观策略的核心:

kotlin 复制代码
// 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
}

性能分析

kotlin 复制代码
场景 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 位),通过位操作同时编码了两个信息:

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

为什么这样设计?

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

老规矩,来看代码:

kotlin 复制代码
// 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

看点例子

kotlin 复制代码
// ========== 场景 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 数组分为三个区域:

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

分配策略(优先级递减)

kotlin 复制代码
// 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")
    }
}

举个栗子

text 复制代码
初始状态(空数组):
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)

kotlin 复制代码
// 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
}

增长示例

text 复制代码
初始状态: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(压缩):

kotlin 复制代码
// 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                // 碎片清零
}

压缩示例

text 复制代码
压缩前(碎片化):
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 Buffer Link Buffer (Slot)
分配 O(N) 移动 Gap O(1) Bump Allocation
增长 O(N) 移动整个子树 O(1) 原地增长(大多数情况)
删除 O(N) 移动 Gap O(1) 标记为死区
碎片整理 无(通过 Gap 避免) O(ActiveSlots)(低频触发)

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


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

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

1. 实验性开关

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

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

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

kotlin 复制代码
// 在 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 中添加:

proguard 复制代码
# 强制开启 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 啊。底层的各种位压缩相信也是看的大家一愣一愣的。那么,你会开启试试吗?

相关推荐
颜酱2 小时前
理解二叉树最近公共祖先(LCA):从基础到变种解析
javascript·后端·算法
Kapaseker7 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
地平线开发者18 小时前
SparseDrive 模型导出与性能优化实战
算法·自动驾驶
董董灿是个攻城狮19 小时前
大模型连载2:初步认识 tokenizer 的过程
算法
地平线开发者19 小时前
地平线 VP 接口工程实践(一):hbVPRoiResize 接口功能、使用约束与典型问题总结
算法·自动驾驶
罗西的思考19 小时前
AI Agent框架探秘:拆解 OpenHands(10)--- Runtime
人工智能·算法·机器学习
HXhlx1 天前
CART决策树基本原理
算法·机器学习
Wect1 天前
LeetCode 210. 课程表 II 题解:Kahn算法+DFS 双解法精讲
前端·算法·typescript
颜酱1 天前
单调队列:滑动窗口极值问题的最优解(通用模板版)
javascript·后端·算法