In-depth understanding of Jetpack Compose core: SlotTable system

introduction

Compose's drawing has three stages, composition > layout > drawing. The latter two processes are similar to the rendering process of traditional views, except that composition is unique to Compose. Compose generates a rendering tree through combination, which is the core capability of the Compose framework, and this process is mainly implemented by SlotTable. This article introduces the SlotTable system.

1. Start with Compose rendering process

The essence of the development process based on Android's native view is to build a view-based rendering tree. When the frame signal arrives, it starts to traverse deeply from the root node, and calls measure/layout/draw in sequence until the rendering of the entire tree is completed. For Compose, there is also such a rendering tree, which we call Compositiion . The node on the tree is LayoutNode . Composition completes the process of measure/layout/draw through LayoutNode and finally displays the UI on the screen. Composition relies on the execution of Composable functions to create and update, the so-called composition and reorganization .

For example, the Composable code above will generate the Composition on the right after execution.

How is a function converted into a LayoutNode after execution? After digging into the source code of Text, we found that Layout is called internally. Layout is a Composable that can customize the layout. All kinds of Composables that we directly use finally achieve different layouts and display effects by calling 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 internally creates LayoutNode through ReusableComposeNode.

  • factoryIt is the factory that creates LayoutNode
  • updateUsed to record the status of the updated Node for subsequent rendering

Continue into 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()
}

We know that the Composable function will be passed to Composer after compilation, and a series of operations are completed in the code based on the passed Composer. The main logic is very clear:

  • Composer#createNodecreate node
  • Updater#updateUpdate Node status
  • content()Continue to execute the inner Composable, creating child nodes.

In addition, some startXXX/endXXX are interspersed in the code, such paired calls are like pushing/popping when deep traversing a tree

startReusableNode
    NodeData // Node数据
    startReplaceableGroup
        GroupData //Group数据
        ... // 子Group
    endGroup
endNode

Not only the built-in Composable like ReusableComposeNode, the compiled code of the Composable function body written by ourselves will also insert a large number of startXXX/endXXX. These are actually the process of Composer accessing SlotTable. The function of Composer is to read and write SlotTable to create and update Composition .

The following figure is the relationship class diagram of Composition, Composer and SlotTable

2. Getting to know SlotTable first

In the previous article, we called the rendering tree generated after the execution of Composable Compositioin. In fact, to be more precise, there are two trees in Composition, one is the LayoutNode tree, which is the tree that actually performs rendering, and LayoutNode can complete the specific rendering process such as measure/layout/draw like View; and the other tree is SlotTable , which records the various data states in the Composition . The state of the traditional view is recorded in the View object. In Compose, it is functional programming-oriented rather than object-oriented, so these states need to be managed and maintained by SlotTable.

All data generated during the execution of Composable functions will be stored in SlotTable, including State, CompositionLocal, key and value of remember, etc. These data will not disappear when the function is popped out of the stack, and can exist across recombination. Composable function will update SlotTable if new data is generated during reorganization.

The data of SlotTable is stored in Slot, and one or more Slots belong to a Group. Group can be understood as each node on the tree. It is said that SlotTable is a tree. In fact, it is not a real tree data structure. It uses linear arrays to express the semantics of a tree.
This can be seen from the definition of SlotTable:

//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 has two array members, groupsthe array stores Group information, and slotsstores the data governed by the Group. The advantage of using arrays instead of structured storage is that the access speed to "trees" can be improved. The frequency of reorganization in Compose is very high. During the reorganization process, the SlotTable will be read and written continuously, and the time complexity of accessing the array is only O(1), so using a linear array structure can help improve the performance of reorganization.

groups is an IntArray, and every 5 Ints form a Group of information

  • key: Group's identifier in SlotTable, unique within the scope of Parent Group
  • Group info: The Bit of Int stores some Group information, such as whether it is a Node, whether it contains Data, etc. These information can be obtained through a bit mask.
  • Parent anchor: Parent's position in groups, that is, the offset relative to the array pointer
  • Size: Group: The number of Slots included
  • Data anchor: The starting position of the associated Slot in the slots array

Slots are where data is actually stored. Any type of data can be generated during the execution of Composable, so the array type is Any?. The number of Slots associated with each Gorup is variable, and the Slots are stored in the slots in the order of the Group they belong to.

groups and slots are not linked lists, so when the capacity is insufficient, they will be expanded.

3. In-depth understanding of Group

The role of groups

The data of the SlotTable is stored in the Slot, why is the unit that acts as a node on the tree not a Slot but a Group? Because Group provides the following functions:

  • Build a tree structure : During the first execution of Composable, a Group node will be created in startXXXGroup and stored in the SlotTable, and the parent-child relationship of the Group will be built by setting the Parent anchor. The parent-child relationship of the Group is the basis for building the rendering tree.

  • Recognition structure change : When the startXXXGroup code is inserted during compilation, an identifiable $keyone (unique within the parent scope) will be generated based on the code position. When combined for the first time, $keyit will be stored in SlotTable along with the Group. During the reorganization, Composer $keycan recognize the addition, deletion or position movement of the Group based on the comparison. In other words, the Group recorded in the SlotTable carries position information, so this mechanism is also called Positional Memoization . Positional Memoization can discover changes in the structure of SlotTable, and finally translate into updates of the LayoutNode tree.

  • The smallest unit of reorganization : Compose's reorganization is "intelligent", and Composable functions or Lambdas can skip unnecessary execution during reorganization. On SlotTtable, these functions or lambdas will be packaged into RestartGroups one by one, so Group is the smallest unit to participate in reorganization.

Type of Group

Composable will generate a variety of different types of startXXXGroup during compilation. When they are inserted into the Group in the SlotTable, they will store auxiliary information to achieve different functions:

startXXXGroup illustrate
startNode/startReusableNode Insert a Group containing Node. For example, in the example of ReusableComposeNode at the beginning of the article, it is shown that startReusableNode is called, and then createNode is called to insert LayoutNode in the Slot.
startRestartGroup Insert a repeatable Group, which may be executed again with reorganization, so RestartGroup is the smallest unit of reorganization.
startReplaceableGroup Insert a Group that can be replaced, for example an if/else code block is a ReplaceableGroup, it can be inserted and removed from the SlotTable in reorganization.
startMovableGroup Insert a group that can be moved, and position movement may occur between sibling groups during reorganization.
startReusableGroup Insert a reusable Group whose internal data can be reused between LayoutNodes, such as the same type of Item in LazyList.

Of course, startXXXGroup is not only used to insert a new Group, but also used to track the existing Group in the SlotTable during reorganization, and
compare it with the code currently being executed. Next, let's see what kind of codes several different types of startXXXGroup appear in.

4. startXXXGroup generated during compilation

Several types of startXXXGroup were introduced earlier. When we write Compose code on weekdays, we have no perception of them, so under what circumstances are they generated? Let's look at several common startXXXGroup generation timings:

startReplaceableGroup

The concept of Positional Memoization was mentioned earlier, that is, when the Group is stored in the SlotTable, it will be generated based on the position $key, which helps to identify the structural changes of the SlotTable. The following code explains this feature more clearly

@Composable
fun ReplaceableGroupTest(condition: Boolean) {
    
    
    if (condition) {
    
    
        Text("Hello") //Text Node 1
    } else {
    
    
        Text("World") //Text Node 2
    }
}

This code, when the condition changes from true to false, means that the render tree should remove the old Text Node 1 and add the new Text Node 2. In the source code, we did not add an identifiable key to the Text. If we only execute it according to the source code, the program cannot recognize the difference between the Node before and after the counditioin change, which may cause the old node status to remain, and the UI does not meet expectations.

How does Compose solve this problem? Take a look at what the above code looks like after compilation (pseudocode):

@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()
    }
}

As you can see, the compiler inserts a RestaceableGroup for each conditional branch of the if/else and adds a different one $key. In this way, when a change conditionoccurs , we can identify that the Group has changed, thereby changing the SlotTable structurally instead of just updating the original Node.

Even if multiple Composables are called inside if/else (for example, multiple Texts may appear), they will only be wrapped in one RestartGroup, because they are always inserted/deleted together, and there is no need to generate a Group separately.

startMovableGroup

@Composable
fun MoveableGroupTest(list: List<Item>) {
    
    
    Column {
    
    
        list.forEach {
    
     
            Text("Item:$it")
        }
    }
}

The above code is an example of displaying a list. Since each line of the list is generated in a for loop, Positional Memoization cannot be implemented based on the code position . If the parameter list changes, such as inserting a new Item, Composer cannot recognize the displacement of the Group at this time, and will delete and Rebuild, which affects reorganization performance.

For such problems that cannot be $keygenerated , Compose provides a solution. You can key {...}manually add a unique index key to easily identify new items and improve reorganization performance. The optimized code is as follows:

//Before Compiler
@Composable
fun MoveableGroupTest(list: List<Item>) {
    
    
    Column {
    
    
        list.forEach {
    
     
            key(izt.id) {
    
     //Unique key
                Text("Item:$it")
            }
            
        }
    }
}

After the above code is compiled, it will be inserted into 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();
            }
        }
    }
}

In the parameter of startMoveableGroup, besides the GroupKey, an auxiliary DataKey is passed in. When there is addition/deletion or displacement in the input list data, MoveableGroup can identify whether it is displacement instead of destruction and reconstruction based on DataKey, so as to improve the performance of reorganization.

startRestartGroup

RestartGroup is a reorganizable unit. Each Composable function we define in our daily code can participate in reorganization independently, so startRestartGroup/endRestartGroup will be inserted into their function bodies. The code before and after compilation is as follows:

// 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)
    }
}

Take a look at what startRestartGroup does

//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)
    //...
}

Here is mainly to create RecomposeScopeImpland store in SlotTable.

  • A Composable function is wrapped in RecomposeScopeImpl. When it needs to participate in recombination, Compose will find it from the SlotTable and call to RecomposeScopeImpl#invalide()mark invalidation. When recombination comes, the Composable function will be re-executed.
  • RecomposeScopeImpl is cached invalidateStackand returned Composer#endRestartGroup()in .
  • updateScopeTo set the Composable function that needs to participate in the reorganization is actually a recursive call to the current function. Note that the return value of endRestartGroup is nullable. If RestartGroupTest does not depend on any state, it does not need to participate in reorganization, and null will be returned at this time.

It can be seen that the generated code is the same regardless of whether Compsoable is necessary to participate in the reorganization. This reduces the complexity of the code generation logic and leaves the judgment to runtime processing.

5. Diff and traversal of SlotTable

SlotTable 的 Diff

In the declarative framework, the update of the rendering tree is implemented through Diff. For example, React implements partial update of the Dom tree through VirtualDom's Diff to improve the performance of UI refresh.

SlotTable is Compose's "Virtual Dom". When Composable is executed for the first time, Group and corresponding Slot data are inserted into SlotTable. When Composable participates in the reorganization, Diff is performed based on the status of the code and the state in the SlotTable, and the state that needs to be updated in the Composition is found, and finally applied to the LayoutNode tree.

This Diff process is also completed in the startXXXGroup process, and the specific implementation is concentrated in 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)
            }
        }
    }
    
    //...
}

The start method has four parameters:

  • key: Generated based on code location during compilation$key
  • objectKey: Auxiliary key added using key{}
  • isNode: Whether the current Group is a Node, in startXXXNode, true will be passed here
  • data: Whether the current Group has a data, the providers will be passed in startProviders

There are many calls to reader and writer in the start method, and they will be introduced later. Here, you only need to know that they can track the current location in SlotTable and complete the read/write operation. The above code has been refined, and the logic is relatively clear:

  • Compare whether the Group is the same based on the key (records in SlotTable and code status), if the Group has not changed, call startReaderGroup to further determine whether the data in the Group has changed
  • If the Group has changed, it means that the Group in the start needs to be added or moved. Use pending.getNext to find out whether the key exists in the Composition. If it exists, it means that the Group needs to be moved, and the shift is performed through slot.moveGroup
  • If the Group needs to be added, according to the Group type, call different writer#startXXX to insert the Group into the SlotTable

The data comparison in the Group is carried out in startReaderGroup, which is relatively simple to implement

private fun startReaderGroup(isNode: Boolean, data: Any?) {
    
    
    //...
    if (data != null && reader.groupAux !== data) {
    
    
        recordSlotTableOperation {
    
     _, slots, _ ->
            slots.updateAux(data)
        }
    }
    //...    
}
  • reader.groupAuxGet the data in the current Slot and compare it with data
  • If different, call recordSlotTableOperationto update the data.

Note that updates to SlotTble are not immediate, as described later.

SlotReader & SlotWriter

As seen above, the reading and writing of SlotTable in the start process needs to be completed by the reader and writer of Composition.

Both writer and reader have corresponding startGroup/endGroup methods. For the writer, startGroup represents the data change to SlotTable, such as inserting or deleting a Group; for the reader, startGroup represents moving the currentGroup pointer to the latest position. currentGroupand currentSlotpoint to the position of the Group and Slot currently being accessed in the SlotTable.

Take a look at the implementation of inserting a Group SlotWriter#startGroupin :

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 
    //...
}
  • insertGroupsIt is used to allocate space for inserting Groups in groups. The concept of Gap Buffer will be involved here, which we will introduce in detail later.
  • initGroup: Initialize the Group information based on the parameters passed in by startGroup. These parameters are generated with different types of startXXXGroup during compilation, and are actually written into SlotTable here
  • Finally update the latest position of currentGroup.

Look again at the implementation SlotReader#startGroupof :

fun startGroup() {
    
    
    //...
    parent = currentGroup
    currentEnd = currentGroup + groups.groupSize(currentGroup)
    val current = currentGroup++
    currentSlot = groups.slotAnchor(current)
    //...
}

The code is very simple, the main thing is to update the positions of currentGroup, currentSlot, etc.

SlotTable creates writer/reader through openWriter/openReader, and needs to call respective close to close after use. Reader can open multiple at the same time, but writer can only open one at a time. In order to avoid concurrency problems, writer and reader cannot be executed at the same time, so the write operation on SlotTable needs to be delayed until after reorganization. Therefore, we see a lot of recordXXX methods in the source code. They record the write operation as a Change to the ChangeList, and then apply it together after the combination is completed.

6. Delayed effect of SlotTable changes

Use changes to record the change list in Composer

//Composer.kt
internal class ComposerImpl {
    
    
    //...
    private val changes: MutableList<Change>,
    //...
    
    private fun record(change: Change) {
    
    
        changes.add(change)
    }
}

ChangeIt is a function that executes specific change logic. The function signature and parameters are as follows:

//Composer.kt
internal typealias Change = (
    applier: Applier<*>,
    slots: SlotWriter,
    rememberManager: RememberManager
) -> Unit
  • applier: The Applier is passed in to apply the changes to the LayoutNode tree, and the Applier will be introduced in detail later
  • slots: Pass in SlotWriter to update SlotTable
  • rememberManger: Introduce RememberManager to register Composition life cycle callbacks, which can complete specific services at specific points in time, such as LaunchedEffect creates CoroutineScope when entering Composition for the first time, and DisposableEffect calls onDispose when leaving Composition, all of which are implemented by registering callbacks here of.

Record Change

Let's take as remember{}an example to see how Change is recorded.
The key and value of remember{} will be recorded in SlotTable as the state in Composition. During reorganization, when the key of the remember changes, the value will recalculate the value and update the 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
}

The above is the source code of remember

  • Composer#changedIn the method, the key stored in SlotTable will be read and compared with key1
  • Composer#cache, rememberedValue will read the current value cached in SlotTable.
  • If a difference is found in the key comparison at this time, call the block to calculate and return a new value, and call updateRememberedValue to update the value to the SlotTable.

updateRememberedValue will eventually be called Composer#updateValue, let's take a look at the specific implementation:

//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 != null) {
    
    
                    previous.composition = null
                    composition.pendingInvalidScopes = true
                }
            }
        }
    }
    //...
}

//记录更新 SlotTable 的 Change

private fun recordSlotTableOperation(forParent: Boolean = false, change: Change) {
    
    
    realizeOperationLocation(forParent)
    record(change) //记录 Change
}

The key code here is recordSlotTableOperationthe call to :

  • Add Change to the changes list, where the content of Change is to update the value to the specified position of the SlotTable through SlotWriter#set, and groupSlotIndexit is the offset of the calculated value in the slots.
  • previousReturns the old value of remember, which can be used for some post-processing. It can also be seen from here that RememberObserver and RecomposeScopeImpl are also states in Composition.
    • RememberObserver is a lifecycle callback, which is registered by RememberManager#forgetting, and RememberObserver will be notified when previous is removed from Composition
    • RecomposeScopeImpl is a recompose unit, pendingInvalidScopes = truewhich means that this recompose unit leaves from Composition.

In addition to remember, other changes related to the SlotTable structure, such as deleting, moving nodes, etc., will also be delayed by changes (the insertion operation has little effect on the reader, so it will be applied immediately). In the example, the change of the remember scene does not involve the update of LayoutNode, so Applierthe parameter . But when the race causes the SlotTable structure to change, the change needs to be applied to the LayoutNoel tree, and the Applier will be used at this time.

Apply Change

As mentioned earlier, the recorded changes wait for the combination to complete before executing.

Recomposer#composeIntialComposition of Composables is done in Composables when they are first executed

//Composition.kt
override fun setContent(content: @Composable () -> Unit) {
    
    
    //...
    this.composable = content
    parent.composeInitial(this, composable)
}

//Recomposer.kt
internal override fun composeInitial(
    composition: ControlledComposition,
    content: @Composable () -> Unit
) {
    
    
    //...
    composing(composition, null) {
    
    
        composition.composeContent(content) //执行组合
    }
    //...

    composition.applyChanges() //应用 Changes
    //...
}

As you can see, right after composition, Composition#applyChanges()apply changes. Likewise, applyChanges is called after each reorganization occurs.

override fun applyChanges() {
    
    
      
      val manager = ...
      //...
      applier.onBeginChanges()
      // Apply all changes
      slotTable.write {
    
     slots ->
          val applier = applier
          changes.fastForEach {
    
     change ->
              change(applier, slots, manager)
          }
          hanges.clear()
       }
       applier.onEndChanges()
       //...
}

See the traversal and execution of changes inside applyChanges. In addition, the start and end of applyChanges will be called back by Applier.

7. UiApplier & LayoutNode

How is the change of the SlotTable structure reflected on the LayoutNode tree?

Earlier we called the rendering tree generated after the execution of Composable Composition. In fact, Composition is a macro-cognition of this rendering tree. To be precise, Composition maintains the LayoutNode tree through Applier and performs specific rendering. Changes in the SlotTable structure will be reflected in the LayoutNode tree with the application of the Change list.

Like View, LayoutNode completes specific rendering through a series of methods measure/layout/drawsuch as and so on . In addition, it also provides methods such as insertAt/removeAt to realize the change of subtree structure. These methods will be called in UiApplier:

//UiApplier.kt
internal class UiApplier(
    root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
    
    

    override fun insertTopDown(index: Int, instance: LayoutNode) {
    
    
        // Ignored
    }

    override fun insertBottomUp(index: Int, instance: LayoutNode) {
    
    
        current.insertAt(index, instance)
    }

    override fun remove(index: Int, count: Int) {
    
    
        current.removeAt(index, count)
    }

    override fun move(from: Int, to: Int, count: Int) {
    
    
        current.move(from, to, count)
    }

    override fun onClear() {
    
    
        root.removeAll()
    }

}

UiApplier is used to update and modify the LayoutNode tree:

  • down()/up()Used to move the position of current to complete the navigation on the tree.
  • insertXXX/remove/moveUsed to modify the structure of the tree. Both insertTopDownand insertBottomUpare used to insert new nodes, but the insertion methods are different, one is bottom-up and the other is top-down. Choosing different insertion orders for different tree structures can help improve performance. For example, UiApplier on the Android side mainly relies on insertBottomUp to insert new nodes, because under the rendering logic of Android, the change of child nodes will affect the re-measurement of parent nodes. Since then, downward insertion can avoid affecting too many parent nodes and improve performance, because attach It is done last.

The execution process of Composable only depends on the Applier abstract interface. UiApplier and LayoutNode are just the corresponding implementations of the Android platform. In theory, we can create our own rendering engine by customizing Applier and Node. For example, Jake Wharton has a project called Mosaic, which implements custom rendering logic by customizing Applier and Node.

Root Node Creation

On the Android platform, we call Composable Activity#setContentin :

//Wrapper.android.kt
internal fun AbstractComposeView.setContent(
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    
    
    //...
    val composeView = ...
    return doSetContent(composeView, parent, content)
}

private fun doSetContent(
    owner: AndroidComposeView,
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    
    
    //...
    val original = Composition(UiApplier(owner.root), parent)
    val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
        as? WrappedComposition
        ?: WrappedComposition(owner, original).also {
    
    
            owner.view.setTag(R.id.wrapped_composition_tag, it)
        }
    wrapped.setContent(content)
    return wrapped
}
  • doSetContentCreate a Composition instance in , and pass in the Applier bound to the Root Node. The Root Node is AndroidComposeViewheld , dispatchDraw from the View world and KeyEvent, touchEventetc. are passed to the Compose world through the Root Node from here.
  • WrappedCompositionIt is a decorator, and it is also used to establish a connection between Composition and AndroidComposeView. Many CompositionLocals from Android that we commonly use are built here, for example, and so LocalContexton LocalConfiguration.

8. SlotTable and Composable life cycle

The life cycle of Composable can be summarized into the following three stages. Now that we know about SlotTable, we can also explain it from the perspective of SlotTable:

  • Enter: In startRestartGroup, store the Group corresponding to Composable into SlotTable
  • Recompose: Find Composable (by RecomposeScopeImpl) in SlotTable, re-execute, and update SlotTable
  • Leave: The Group corresponding to the Composable is removed from the SlotTable.

Using the side effect API in Composable can act as a Composable lifecycle callback to use

DisposableEffect(Unit) {
    
    
    //callback when entered the Composition & recomposed
    onDispose {
    
     
        //callback for leaved the Composition
    }
}

Let's take DisposableEffect as an example to see how the life cycle callback is completed based on the SlotTable system. Take a look at the implementation of DisposableEffect, the code is as follows:

@Composable
@NonRestartableComposable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    
    
    remember(key1) {
    
     DisposableEffectImpl(effect) }
}


private class DisposableEffectImpl(
    private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
    
    
    private var onDispose: DisposableEffectResult? = null

    override fun onRemembered() {
    
    
        onDispose = InternalDisposableEffectScope.effect()
    }

    override fun onForgotten() {
    
    
        onDispose?.dispose()
        onDispose = null
    }

    override fun onAbandoned() {
    
    
        // Nothing to do as [onRemembered] was not called.
    }
}

It can be seen that the essence of DisposableEffect is to use remember to store a DisposableEffectImpl in SlotTable, which is an implementation of RememberObserver. DisposableEffectImpl will receive onRememberedand onForgottencallbacks as the parent Group enters and leaves the SlotTable.

Remember the applyChanges mentioned earlier, it happens after the reorganization is complete

override fun applyChanges() {
    
    
      
  val manager = ... // 创建 RememberManager
  //...
  // Apply all changes
  slotTable.write {
    
     slots ->
      //...
      changes.fastForEach {
    
     change ->
          //应用 changes, 将 ManagerObserver 注册进 RememberMananger
          change(applier, slots, manager)
      }
      //...
  }
  //...
  manager.dispatchRememberObservers() //分发回调
}

As mentioned earlier, the changes that occur during the SlotTable write operation will be applied uniformly here, and of course also include the changes of the record when DisposableEffectImpl is inserted/deleted. Specifically, it is the registration of ManagerObserver, which will be called back dispatchRememberObserversin

Restructuring is optimistic

In the official website document, there is such a passage in the introduction of reorganization: Reorganization is "optimistic"

When recomposition is canceled, Compose discards the UI tree from the recomposition. If you have any side-effects that depend on the UI being displayed, the side-effect will be applied even if composition is canceled. This can lead to inconsistent app state.

Ensure that all composable functions and lambdas are idempotent and side-effect free to handle optimistic recomposition.

https://developer.android.com/jetpack/compose/mental-model#optimistic

Many people will not understand this passage at first glance, but after reading the source code, I believe they can understand its meaning. The so-called "optimistic" here means that the reorganization of Compose is always assumed to be uninterrupted. Once an interruption occurs, the operations performed in Composable will not be truly reflected in SlotTable, because we know from the source code that applyChanges occurs after composiiton successfully ends.

If the composition is interrupted, the state you read in the Composable function is likely to be inconsistent with the final SlotTable. Therefore, if we need to perform some side effect processing based on the state of Composition, we must use a side effect API package such as DisposableEffect, because through the source code we also know that the callback of DisposableEffect is executed by applyChanges. At this time, we can ensure that the reorganization has been completed, and the obtained state is the same as SlotTable is consistent.

9. SlotTable 与 GapBuffer

As mentioned earlier, startXXXGroup will Diff with Group in SlotTable. If the comparison is not equal, it means that the structure of SlotTable has changed, and Group needs to be inserted/deleted/moved. This process is implemented based on Gap Buffer.

The concept of Gap Buffer comes from the data structure in the text editor. It can be understood as a slidable and scalable buffer area in a linear array. Specifically, in SlotTable, it is an unused area in groups. This area can be moved in groups , to improve the update efficiency when the SlotTble structure changes, the following examples illustrate:

@Composable
fun Test(condition: Boolean) {
    
     
    if (condition) {
    
    
        Node1()
        Node2()
    }
    Node3()
    Node4()
}

SlotTable initially only has Node3 and Node4, and then according to the status change, Node1 and Node2 need to be inserted. If there is no Gap Buffer during this process, the change of SlotTable is shown in the following figure:

Every time a new Node is inserted, the existing Nodes in the SlotTable will be moved, which is inefficient. Let's take a look at the behavior after introducing Gap Buffer:

When inserting a new Node, the Gap in the array will be moved to the position to be inserted, and then the new Node will be inserted. Inserting Node1, Node2 and even their sub-Nodes are all filling the free area of ​​the Gap, which will not affect the movement of the Node.
Take a look at the specific implementation of mobile Gap, the relevant code is as follows:

//SlotTable.kt
private fun moveGroupGapTo(index: Int) {
    
    

    //...
            val groupPhysicalAddress = index * Group_Fields_Size
            val groupPhysicalGapLen = gapLen * Group_Fields_Size
            val groupPhysicalGapStart = gapStart * Group_Fields_Size
            if (index < gapStart) {
    
    
                groups.copyInto(
                    destination = groups,
                    destinationOffset = groupPhysicalAddress + groupPhysicalGapLen,
                    startIndex = groupPhysicalAddress,
                    endIndex = groupPhysicalGapStart
                )
            } 
      //...     
}
  • Indexis the position to insert the Group, that is, the Gap needs to be moved here
  • Group_Fields_SizeIt is the length of the unit Group in groups, which is currently a constant of 5.

The meaning of several temporary variables is also very clear:

  • groupPhysicalAddress: currently need to insert the address of the group
  • groupPhysicalGapLen: the length of the current gap
  • groupPhysicalGapStart: The starting address of the current Gap

At that index < gapStatetime , the Gap needs to be moved forward to the index position to prepare for the new insertion. From the copyIntofollowing parameters, we can see that the forward movement of the Gap is actually achieved by moving the group backward, that is, startIndexthe Node at is copied to the new position of the Gap, as shown in the figure below:

In this way, we don't need to really move the Gap, groupPyhsicalAddressjust , and the new Node1 will be inserted here. Of course, after the groups are moved, the associated information such as the anchor should also be updated accordingly.

Finally, look at the Gap movement when deleting Node. The principle is similar:

Move the Gap before the Group to be deleted, and then delete the Node. In this way, the deletion process is actually just moving the end position of the Gap, which is very efficient and ensures the continuity of the Gap.

10. Summary

The SlotTable system is the most important link in the entire process of Compose from composition to rendering to the screen. Combined with the following figure, let's review the entire process:

  1. Composable source code will be inserted into startXXXGroup/endXXXGroup template code during compilation for tree traversal of SlotTable.
  2. In the first combination of Composable, startXXXGroup inserts the Group in the SlotTable and uses $key to identify the position of the Group in the code
  3. During the reorganization, startXXXGroup will traverse and Diff the SlotTable, and delay updating the SlotTable through changes, and apply it to the LayoutNode tree at the same time
  4. When the rendered frame arrives, LayoutNode performs measure > layout > draw for the changed part to complete the partial refresh of the UI.

Guess you like

Origin blog.csdn.net/vitaviva/article/details/125478624