倘若你略微了解过 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,接着调用了 Button,Button 里面又调用了一个 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),每一步都伴随着痛苦的内存操作:
- Insert Slots:在目标位置为 Slots 腾出空间(移动 Slot Gap,更新沿途所有 Anchor)。
- Insert Groups:在目标位置为 Groups 腾出空间(移动 Group Gap)。
- Copy Groups:将 Group 元数据复制到新位置。
- Copy Slots:将 Slot 数据复制到新位置。
- Fix Anchors (Moved):修正被移动 Group 内部的 Slot Anchor(因为 Slot 位置变了)。
- Update External Anchors:更新外部对象持有的 Anchor(因为 Group 位置变了)。
- Remove Old Groups:删除旧位置的 Group(再次移动 Gap)。
- Fix Parent Anchors:修正受影响的父节点信息。
- 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. 逻辑结构:隐式的树
通过 Next、Parent 和 Child 这三个"指针"(实际上是数组索引),我们在扁平数组上构建了一棵完全动态的树。
假设我们有三个 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
- 通过
Next和Child指针,我们在乱序的数组上构建了有序的树 - 这意味着:移动、插入、删除都不需要拷贝内存,只需修改指针!
举个栗子:我们要在 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, ...]
树的遍历(深度优先):
- 从 P 开始 →
child(P)= 200,进入 A - 从 A 开始 →
child(A)= 300,进入 B - 从 B 开始 →
child(B)= -1,无子节点;next(B)= 450,进入 C - 从 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" 的思想:
- 大多数情况下(顺序遍历、局部修改),context 有效,享受 O(1) 极速
- 极少数情况(随机操作、过期 handle),context 失效,退化为 O(N) 扫描
- 整体收益:大多数的 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)
这样的设计保证了:
- 结构和数据分离:Group(树结构)和 Slot(数据)独立管理,各自优化
- 延迟回收:删除 Group 时不立即整理 Slot,批量压缩摊销开销
- 位压缩:大多数小对象零额外开销(只放到低 4 位中),少量大对象用 Map 兜底( Map 还是特别优化的稀疏 Map,底层也是 Arra)
- 预留 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 中,有一个实验性开关:
// 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;
}
这会使得:
- 运行时行为:确保
isLinkBufferComposerEnabled永远返回true。 - 死代码消除: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 啊。底层的各种位压缩相信也是看的大家一愣一愣的。那么,你会开启试试吗?