深入理解 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 添加的辅助 key
  • isNode:当前 Group 是否是一个 Node,在 startXXXNode 中,此处会传入 true
  • data:当前 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 指针到最新位置。currentGroupcurrentSlot 指向 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 树,在后文详细介绍 Applier
  • slots:传入 SlotWriter 用于更新 SlotTable
  • rememberManger:传入 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 编译器

Jetpack Compose 深入探索系列六:Compose runtime 高级用例

Jetpack Compose 深入探索系列三:Compose runtime