深入理解 Jetpack Compose 内核:SlotTable 系统
Posted fundroid_方卓
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解 Jetpack Compose 内核:SlotTable 系统相关的知识,希望对你有一定的参考价值。
引言
Compose 的绘制有三个阶段,组合 > 布局 > 绘制。后两个过程与传统视图的渲染过程相近,唯独组合是 Compose 所特有的。Compose 通过组合生成渲染树,这是 Compose 框架的核心能力,而这个过程主要是依赖 SlotTable 实现的,本文就来介绍一下 SlotTable 系统。
1. 从 Compose 渲染过程说起
基于 android 原生视图的开发过程,其本质就是构建一棵基于 View 的渲染树,当帧信号到达时从根节点开始深度遍历,依次调用 measure/layout/draw,直至完成整棵树的渲染。对于 Compose 来说也存在这样一棵渲染树,我们将其称为 Compositiion,树上的节点是 LayoutNode,Composition 通过 LayoutNode 完成 measure/layout/draw 的过程最终将 UI 显示到屏幕上。Composition 依靠 Composable 函数的执行来创建以及更新,即所谓的组合和重组。
例如上面的 Composable 代码,经过执行后会生成右侧的 Composition。
一个函数经过执行是如何转换成 LayoutNode 的呢?深入 Text 的源码后发现其内部调用了 Layout, Layout 是一个可以自定义布局的 Composable,我们直接使用的各类 Composable 最终都是通过调用 Layout 来实现不同的布局和显示效果。
//Layout.kt
@Composable inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val viewConfiguration = LocalViewConfiguration.current
ReusableComposeNode<ComposeUiNode, Applier<Any>>(
factory = ComposeUiNode.Constructor,
update =
set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
set(density, ComposeUiNode.SetDensity)
set(layoutDirection, ComposeUiNode.SetLayoutDirection)
set(viewConfiguration, ComposeUiNode.SetViewConfiguration)
,
skippableUpdate = materializerOf(modifier),
content = content
)
Layout 内部通过 ReusableComposeNode 创建 LayoutNode。
factory
就是创建 LayoutNode 的工厂update
用来记录会更新 Node 的状态用于后续渲染
继续进入 ReusableComposeNode :
//Composables.kt
inline fun <T, reified E : Applier<*>> ReusableComposeNode(
noinline factory: () -> T,
update: @DisallowComposableCalls Updater<T>.() -> Unit,
noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
content: @Composable () -> Unit
)
//...
$composer.startReusableNode()
//...
$composer.createNode(factory)
//...
Updater<T>(currentComposer).update()
//...
$composer.startReplaceableGroup(0x7ab4aae9)
content()
$composer.endReplaceableGroup()
$composer.endNode()
我们知道 Composable 函数经过编译后会传入 Composer, 代码中基于传入的 Composer 完成了一系列操作,主逻辑很清晰:
Composer#createNode
创建节点Updater#update
更新 Node 状态content()
继续执行内部 Composable,创建子节点。
此外,代码中还穿插着了一些 startXXX/endXXX ,这样的成对调用就好似对一棵树进行深度遍历时的压栈/出栈
startReusableNode
NodeData // Node数据
startReplaceableGroup
GroupData //Group数据
... // 子Group
endGroup
endNode
不只是 ReusableComposeNode 这样的内置 Composable,我们自己写的 Composable 函数体经过编译后的代码也会插入大量的 startXXX/endXXX,这些其实都是 Composer 对 SlotTable 访问的过程,Composer 的职能就是通过对 SlotTable 的读写来创建和更新 Composition。
下图是 Composition,Composer 与 SlotTable 的关系类图
2. 初识 SlotTable
前文我们将 Composable 执行后生成的渲染树称为 Compositioin。其实更准确来说,Composition 中存在两棵树,一棵是 LayoutNode 树,这是真正执行渲染的树,LayoutNode 可以像 View 一样完成 measure/layout/draw 等具体渲染过程;而另一棵树是 SlotTable,它记录了 Composition 中的各种数据状态。 传统视图的状态记录在 View 对象中,在 Compose 面向函数编程而不面向对象,所以这些状态需要依靠 SlotTable 进行管理和维护。
Composable 函数执行过程中产生的所有数据都会存入 SlotTable, 包括 State、CompositionLocal,remember 的 key 与 value 等等 ,这些数据不随函数的出栈而消失,可以跨越重组存在。Composable 函数在重组中如果产生了新数据则会更新 SlotTable。
SlotTable 的数据存储在 Slot 中,一个或多个 Slot 又归属于一个 Group。可以将 Group 理解为树上的一个个节点。说 SlotTable 是一棵树,其实它并非真正的树形数据结构,它用线性数组来表达一棵树的语义,从 SlotT
able 的定义中可以看到这一点:
//SlotTable.kt
internal class SlotTable : CompositionData, Iterable<CompositionGroup>
/**
* An array to store group information that is stored as groups of [Group_Fields_Size]
* elements of the array. The [groups] array can be thought of as an array of an inline
* struct.
*/
var groups = IntArray(0)
private set
/**
* An array that stores the slots for a group. The slot elements for a group start at the
* offset returned by [dataAnchor] of [groups] and continue to the next group's slots or to
* [slotsSize] for the last group. When in a writer the [dataAnchor] is an anchor instead of
* an index as [slots] might contain a gap.
*/
var slots = Array<Any?>(0) null
private set
SlotTable 有两个数组成员,groups
数组存储 Group 信息,slots
存储 Group 所辖的数据。用数组替代结构化存储的好处是可以提升对“树”的访问速度。 Compose 中重组的频率很高,重组过程中会不断的对 SlotTable 进行读写,而访问数组的时间复杂度只有 O(1),所以使用线性数组结构有助于提升重组的性能。
groups 是一个 IntArray,每 5 个 Int 为一组构成一个 Group 的信息
key
: Group 在 SlotTable 中的标识,在 Parent Group 范围内唯一Group info
: Int 的 Bit 位中存储着一些 Group 信息,例如是否是一个 Node,是否包含 Data 等,这些信息可以通过位掩码来获取。Parent anchor
: Parent 在 groups 中的位置,即相对于数组指针的偏移Size: Group
: 包含的 Slot 的数量Data anchor
:关联 Slot 在 slots 数组中的起始位置
slots 是真正存储数据的地方,Composable 执行过程中可以产生任意类型的数据,所以数组类型是 Any?
。每个 Gorup 关联的 Slot 数量不定,Slot 在 slots 中按照所属 Group 的顺序依次存放。
groups 和 slots 不是链表,所以当容量不足时,它们会进行扩容。
3. 深入理解 Group
Group 的作用
SlotTable 的数据存储在 Slot 中,为什么充当树上节点的单位不是 Slot 而是 Group 呢?因为 Group 提供了以下几个作用:
-
构建树形结构: Composable 首次执行过程中,在 startXXXGroup 中会创建 Group 节点存入 SlotTable,同时通过设置 Parent anchor 构建 Group 的父子关系,Group 的父子关系是构建渲染树的基础。
-
识别结构变化: 编译期插入 startXXXGroup 代码时会基于代码位置生成可识别的
$key
(parent 范围内唯一)。在首次组合时$key
会随着 Group 存入 SlotTable,在重组中,Composer 基于$key
的比较可以识别出 Group 的增、删或者位置移动。换言之,SlotTable 中记录的 Group 携带了位置信息,故这种机制也被称为 Positional Memoization。Positional Memoization 可以发现 SlotTable 结构上的变化,最终转化为 LayoutNode 树的更新。 -
重组的最小单位: Compose 的重组是“智能”的,Composable 函数或者 Lambda 在重组中可以跳过不必要的执行。在 SlotTtable 上,这些函数或 lambda 会被包装为一个个 RestartGroup ,因此 Group 是参与重组的最小单位。
Group 的类型
Composable 在编译期会生成多种不同类型的 startXXXGroup,它们在 SlotTable 中插入 Group 的同时,会存入辅助信息以实现不同的功能:
startXXXGroup | 说明 |
---|---|
startNode/startReusableNode | 插入一个包含 Node 的 Group。例如文章开头 ReusableComposeNode 的例子中,显示调用了 startReusableNode ,而后调用 createNode 在 Slot 中插入 LayoutNode。 |
startRestartGroup | 插入一个可重复执行的 Group,它可能会随着重组被再次执行,因此 RestartGroup 是重组的最小单元。 |
startReplaceableGroup | 插入一个可以被替换的 Group,例如一个 if/else 代码块就是一个 ReplaceableGroup,它可以在重组中被插入后者从 SlotTable 中移除。 |
startMovableGroup | 插入一个可以移动的 Group,在重组中可能在兄弟 Group 之间发生位置移动。 |
startReusableGroup | 插入一个可复用的 Group,其内部数据可在 LayoutNode 之间复用,例如 LazyList 中同类型的 Item。 |
当然 startXXXGroup 不止用于插入新 Group,在重组中也会用来追踪 SlotTable 的已有 Group,与当前执行
中的代码情况进行比较。接下来我们看下几种不同类型的 startXXXGroup 出现在什么样的代码中。
4. 编译期生成的 startXXXGroup
前面介绍了 startXXXGroup 的几种类型,我们平日在写 Compose 代码时,对他们毫无感知,那么他们分别是在何种情况下生成的呢?下面看几种常见的 startXXXGroup 的生成时机:
startReplaceableGroup
前面提到过 Positional Memoization 的概念,即 Group 存入 SlotTable 时,会携带基于位置生成的 $key
,这有助于识别 SlotTable 的结构变化。下面的代码能更清楚地解释这个特性
@Composable
fun ReplaceableGroupTest(condition: Boolean)
if (condition)
Text("Hello") //Text Node 1
else
Text("World") //Text Node 2
这段代码,当 condition 从 true 变为 false,意味着渲染树应该移除旧的 Text Node 1 ,并添加新的 Text Node 2。源码中我们没有为 Text 添加可辨识的 key,如果仅按照源码执行,程序无法识别出 counditioin 变化前后 Node 的不同,这可能导致旧的节点状态依然残留,UI 不符预期。
Compose 如何解决这个问题呢,看一下上述代码编译后的样子(伪代码):
@Composable
fun ReplaceableGroupTest(condition: Boolean, $composer: Composer?, $changed: Int)
if (condition)
$composer.startReplaceableGroup(1715939608)
Text("Hello")
$composer.endReplaceableGroup()
else
$composer.startReplaceableGroup(1715939657)
Text("World")
$composer.endReplaceableGroup()
可以看到,编译器为 if/else 每个条件分支都插入了 RestaceableGroup ,并添加了不同的 $key
。这样当 condition
发生变化时,我们可以识别 Group 发生了变化,从而从结构上变更 SlotTable,而不只是更新原有 Node。
if/else 内部即使调用了多个 Composable(比如可能出现多个 Text) ,它们也只会包装在一个 RestartGroup ,因为它们总是被一起插入/删除,无需单独生成 Group 。
startMovableGroup
@Composable
fun MoveableGroupTest(list: List<Item>)
Column
list.forEach
Text("Item:$it")
上面代码是一个显示列表的例子。由于列表的每一行在 for 循环中生成,无法基于代码位置实现 Positional Memoization,如果参数 list 发生了变化,比如插入了一个新的 Item,此时 Composer 无法识别出 Group 的位移,会对其进行删除和重建,影响重组性能。
针对这类无法依靠编译器生成 $key
的问题,Compose 给了解决方案,可以通过 key ...
手动添加唯一索引 key,便于识别 Item 的新增,提升重组性能。经优化后的代码如下:
//Before Compiler
@Composable
fun MoveableGroupTest(list: List<Item>)
Column
list.forEach
key(izt.id) //Unique key
Text("Item:$it")
上面代码经过编译后会插入 startMoveableGroup:
@Composable
fun MoveableGroupTest(list: List<Item>, $composer: Composer?, $changed: Int)
Column
list.forEach
key(it.id)
$composer.startMovableGroup(-846332013, Integer.valueOf(it));
Text("Item:$it")
$composer.endMovableGroup();
startMoveableGroup 的参数中除了 GroupKey 还传入了一个辅助的 DataKey。当输入的 list 数据中出现了增/删或者位移时,MoveableGroup 可以基于 DataKey 识别出是否是位移而非销毁重建,提升重组的性能。
startRestartGroup
RestartGroup 是一个可重组单元,我们在日常代码中定义的每个 Composable 函数都可以单独参与重组,因此它们的函数体中都会插入 startRestartGroup/endRestartGroup,编译前后的代码如下:
// Before compiler (sources)
@Composable
fun RestartGroupTest(str: String)
Text(str)
// After compiler
@Composable
fun RestartGroupTest(str: String, $composer: Composer<*>, $changed: Int)
$composer.startRestartGroup(-846332013)
// ...
Text(str)
$composer.endRestartGroup()?.updateScope next ->
RestartGroupTest(str, next, $changed or 0b1)
看一下 startRestartGroup 做了些什么
//Composer.kt
fun startRestartGroup(key: Int): Composer
start(key, null, false, null)
addRecomposeScope()
return this
private fun addRecomposeScope()
//...
val scope = RecomposeScopeImpl(composition as CompositionImpl)
invalidateStack.push(scope)
updateValue(scope)
//...
这里主要是创建 RecomposeScopeImpl
并存入 SlotTable 。
- RecomposeScopeImpl 中包裹了一个 Composable 函数,当它需要参与重组时,Compose 会从 SlotTable 中找到它并调用
RecomposeScopeImpl#invalide()
标记失效,当重组来临时 Composable 函数被重新执行。 - RecomposeScopeImpl 被缓存到
invalidateStack
,并在Composer#endRestartGroup()
中返回。 updateScope
为其设置需要参与重组的 Composable 函数,其实就是对当前函数的递归调用。注意 endRestartGroup 的返回值是可空的,如果 RestartGroupTest 中不依赖任何状态则无需参与重组,此时将返回 null。
可见,无论 Compsoable 是否有必要参与重组,生成代码都一样。这降低了代码生成逻辑的复杂度,将判断留到运行时处理。
5. SlotTable 的 Diff 与遍历
SlotTable 的 Diff
声明式框架中,渲染树的更新都是通过 Diff 实现的,比如 React 通过 VirtualDom 的 Diff 实现 Dom 树的局部更新,提升 UI 刷新的性能。
SlotTable 就是 Compose 的 “Virtual Dom”,Composable 初次执行时在 SlotTable 中插入 Group 和对应的 Slot 数据。 当 Composable 参与重组时,基于代码现状与 SlotTable 中的状态进行 Diff,发现 Composition 中需要更新的状态,并最终应用到 LayoutNode 树。
这个 Diff 的过程也是在 startXXXGroup 过程中完成的,具体实现都集中在 Composer#start()
:
//Composer.kt
private fun start(key: Int, objectKey: Any?, isNode: Boolean, data: Any?)
//...
if (pending == null)
val slotKey = reader.groupKey
if (slotKey == key && objectKey == reader.groupObjectKey)
// 通过 key 的比较,确定 group 节点没有变化,进行数据比较
startReaderGroup(isNode, data)
else
// group 节点发生了变化,创建 pending 进行后续处理
pending = Pending(
reader.extractKeys(),
nodeIndex
)
//...
if (pending != null)
// 寻找 gorup 是否在 Compositon 中存在
val keyInfo = pending.getNext(key, objectKey)
if (keyInfo != null)
// group 存在,但是位置发生了变化,需要借助 GapBuffer 进行节点位移
val location = keyInfo.location
reader.reposition(location)
if (currentRelativePosition > 0)
// 对 Group 进行位移
recordSlotEditingOperation _, slots, _ ->
slots.moveGroup(currentRelativePosition)
startReaderGroup(isNode, data)
else
//...
val startIndex = writer.currentGroup
when
isNode -> writer.startNode(Composer.Empty)
data != null -> writer.startData(key, objectKey ?: Composer.Empty, data)
else -> writer.startGroup(key, objectKey ?: Composer.Empty)
//...
start 方法有四个参数:
key
: 编译期基于代码位置生成的$key
objectKey
: 使用 key 添加的辅助 keyisNode
:当前 Group 是否是一个 Node,在 startXXXNode 中,此处会传入 truedata
:当前 Group 是否有一个数据,在 startProviders 中会传入 providers
start 方法中有很多对 reader 和 writer 的调用,稍后会对他们作介绍,这里只需要知道他们可以追踪 SlotTable 中当前应该访问的位置,并完成读/写操作。上面的代码已经经过提炼,逻辑比较清晰:
- 基于 key 比较 Group 是否相同(SlotTable 中的记录与代码现状),如果 Group 没有变化,则调用 startReaderGroup 进一步判断 Group 内的数据是否发生变化
- 如果 Group 发生了变化,则意味着 start 中 Group 需要新增或者位移,通过 pending.getNext 查找 key 是否在 Composition 中存在,若存在则表示需要 Group 需要位移,通过 slot.moveGroup 进行位移
- 如果 Group 需要新增,则根据 Group 类型,分别调用不同的 writer#startXXX 将 Group 插入 SlotTable
Group 内的数据比较是在 startReaderGroup 中进行的,实现比较简单
private fun startReaderGroup(isNode: Boolean, data: Any?)
//...
if (data != null && reader.groupAux !== data)
recordSlotTableOperation _, slots, _ ->
slots.updateAux(data)
//...
reader.groupAux
获取当前 Slot 中的数据与 data 做比较- 如果不同,则调用
recordSlotTableOperation
对数据进行更新。
注意对 SlotTble 的更新并非立即生效,这在后文会作介绍。
SlotReader & SlotWriter
上面看到,start 过程中对 SlotTable 的读写都需要依靠 Composition 的 reader 和 writer 来完成。
writer 和 reader 都有对应的 startGroup/endGroup 方法。对于 writer 来说 startGroup 代表对 SlotTable 的数据变更,例如插入或删除一个 Group ;对于 reader 来说 startGroup 代表着移动 currentGroup 指针到最新位置。currentGroup
和 currentSlot
指向 SlotTable 当前访问中的 Group 和 Slot 的位置。
看一下 SlotWriter#startGroup
中插入一个 Group 的实现:
private fun startGroup(key: Int, objectKey: Any?, isNode: Boolean, aux: Any?)
//...
insertGroups(1) // groups 中分配新的位置
val current = currentGroup
val currentAddress = groupIndexToAddress(current)
val hasObjectKey = objectKey !== Composer.Empty
val hasAux = !isNode && aux !== Composer.Empty
groups.initGroup( //填充 Group 信息
address = currentAddress, //Group 的插入位置
key = key, //Group 的 key
isNode = isNode, //是否是一个 Node
hasDataKey = hasObjectKey, //是否有 DataKey
hasData = hasAux, //是否包含数据
parentAnchor = parent, //关联Parent
dataAnchor = currentSlot //关联Slot地址
)
//...
val newCurrent = current + 1
this.parent = current //更新parent
this.currentGroup = newCurrent
//...
insertGroups
用来在 groups 中分配插入 Group 用的空间,这里会涉及到 Gap Buffer 概念,我们在后文会详细介绍。initGroup
:基于 startGroup 传入的参数初始化 Group 信息。这些参数都是在编译期随着不同类型的 startXXXGroup 生成的,在此处真正写入到 SlotTable 中- 最后更新 currentGroup 的最新位置。
再看一下 SlotReader#startGroup
的实现:
fun startGroup()
//...
parent = currentGroup
currentEnd = currentGroup + groups.groupSize(currentGroup)
val current = currentGroup++
currentSlot = groups.slotAnchor(current)
//...
代码非常简单,主要就是更新 currentGroup,currentSlot 等的位置。
SlotTable 通过 openWriter/openReader 创建 writer/reader,使用结束需要调用各自的 close 关闭。reader 可以 open 多个同时使用,而 writer 同一时间只能 open 一个。为了避免发生并发问题, writer 与 reader 不能同时执行,所以对 SlotTable 的 write 操作需要延迟到重组后进行。因此我们在源码中看到很多 recordXXX 方法,他们将写操作提为一个 Change 记录到 ChangeList,等待组合结束后再一并应用。
6. SlotTable 变更延迟生效
Composer 中使用 changes 记录变动列表
//Composer.kt
internal class ComposerImpl
//...
private val changes: MutableList<Change>,
//...
private fun record(change: Change)
changes.add(change)
Change
是一个函数,执行具体的变动逻辑,函数签名即参数如下:
//Composer.kt
internal typealias Change = (
applier: Applier<*>,
slots: SlotWriter,
rememberManager: RememberManager
) -> Unit
applier
: 传入 Applier 用于将变化应用到 LayoutNode 树,在后文详细介绍 Applierslots
:传入 SlotWriter 用于更新 SlotTablerememberManger
:传入 RememberManager 用来注册 Composition 生命周期回调,可以在特定时间点完成特定业务,比如 LaunchedEffect 在首次进入 Composition 时创建 CoroutineScope, DisposableEffect 在从 Composition 中离开时调用 onDispose ,这些都是通过在这里注册回调实现的。
记录 Change
我们以 remember
为例看一下 Change 如何被记录。
remember 的 key 和 value 都会作为 Composition 中的状态记录到 SlotTable 中。重组中,当 remember 的 key 发生变化时,value 会重新计算 value 并更新 SlotTable。
//Composables.kt
@Composable
inline fun <T> remember(
key1: Any?,
calculation: @DisallowComposableCalls () -> T
): T
return currentComposer.cache(currentComposer.changed(key1), calculation)
//Composer.kt
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T
@Suppress("UNCHECKED_CAST")
return rememberedValue().let
if (invalid || it === Composer.Empty)
val value = block()
updateRememberedValue(value)
value
else it
as T
如上是 remember 的源码
Composer#changed
方法中会读取 SlotTable 中存储的 key 与 key1 进行比较Composer#cache
中,rememberedValue 会读取 SlotTable 中缓存的当前 value。- 如果此时 key 的比较中发现了不同,则调用 block 计算并返回新的 value,同时调用 updateRememberedValue 将 value 更新到 SlotTable。
updateRememberedValue 最终会调用 Composer#updateValue
,看一下具体实现:
//Composer.kt
internal fun updateValue(value: Any?)
//...
val groupSlotIndex = reader.groupSlotIndex - 1 //更新位置Index
recordSlotTableOperation(forParent = true) _, slots, rememberManager ->
if (value is RememberObserver)
rememberManager.remembering(value)
when (val previous = slots.set(groupSlotIndex, value)) //更新
is RememberObserver ->
rememberManager.forgetting(previous)
is RecomposeScopeImpl ->
val composition = previous.composition
if (composition != 以上是关于深入理解 Jetpack Compose 内核:SlotTable 系统的主要内容,如果未能解决你的问题,请参考以下文章
社区说 | 深入理解 Jetpack Compose 的设计理念
社区说 | 深入理解 Jetpack Compose 的设计理念
深入详解 Jetpack Compose | 优化 UI 构建
Jetpack Compose 深入探索系列二:Compose 编译器