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.
factory
It is the factory that creates LayoutNodeupdate
Used 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#createNode
create nodeUpdater#update
Update Node statuscontent()
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, groups
the array stores Group information, and slots
stores 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 GroupGroup 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 pointerSize: Group
: The number of Slots includedData 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
$key
one (unique within the parent scope) will be generated based on the code position. When combined for the first time,$key
it will be stored in SlotTable along with the Group. During the reorganization, Composer$key
can 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 condition
occurs , 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 $key
generated , 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 RecomposeScopeImpl
and 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
invalidateStack
and returnedComposer#endRestartGroup()
in . updateScope
To 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 heredata
: 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.groupAux
Get the data in the current Slot and compare it with data- If different, call
recordSlotTableOperation
to 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. currentGroup
and currentSlot
point 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#startGroup
in :
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
It 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#startGroup
of :
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)
}
}
Change
It 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 laterslots
: Pass in SlotWriter to update SlotTablerememberManger
: 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#changed
In the method, the key stored in SlotTable will be read and compared with key1Composer#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 recordSlotTableOperation
the 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
groupSlotIndex
it is the offset of the calculated value in the slots. previous
Returns 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 = true
which 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 Applier
the 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#composeIntial
Composition 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/draw
such 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/move
Used to modify the structure of the tree. BothinsertTopDown
andinsertBottomUp
are 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#setContent
in :
//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
}
doSetContent
Create a Composition instance in , and pass in the Applier bound to the Root Node. The Root Node isAndroidComposeView
held , dispatchDraw from the View world andKeyEvent
,touchEvent
etc. are passed to the Compose world through the Root Node from here.WrappedComposition
It 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 soLocalContext
onLocalConfiguration
.
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 SlotTableRecompose
: Find Composable (by RecomposeScopeImpl) in SlotTable, re-execute, and update SlotTableLeave
: 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 onRemembered
and onForgotten
callbacks 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 dispatchRememberObservers
in
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
)
}
//...
}
Index
is the position to insert the Group, that is, the Gap needs to be moved hereGroup_Fields_Size
It 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 groupgroupPhysicalGapLen
: the length of the current gapgroupPhysicalGapStart
: The starting address of the current Gap
At that index < gapState
time , the Gap needs to be moved forward to the index position to prepare for the new insertion. From the copyInto
following parameters, we can see that the forward movement of the Gap is actually achieved by moving the group backward, that is, startIndex
the 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, groupPyhsicalAddress
just , 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:
- Composable source code will be inserted into startXXXGroup/endXXXGroup template code during compilation for tree traversal of SlotTable.
- 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
- 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
- When the rendered frame arrives, LayoutNode performs measure > layout > draw for the changed part to complete the partial refresh of the UI.