This is the seventh day of my participation in the More text Challenge. For details, see more text Challenge
There’s one going on right nowJetpack Compose Chinese handbookThis project aims to help developers better understand and master the Compose framework. It is still under construction. Welcome to follow and join! This article has been included in the manual, welcome to consult
compose.runtime
Jetpack Compose is more than just a UI framework, it’s a general purpose NodeTree management engine. This article describes how compose. Runtime provides support for compose. UI via NodeTree.
As you all know, Jetpack Compose is not limited to Android, and some projects such as Compose For Desktop, Compose For Web have been released successively. In the future, maybe there will be Compose For iOS. Compose is able to achieve a similar declarative UI development experience across platforms, thanks to its layered design.
Compose is divided into 6 layers from bottom to top in the code:
Modules | Description |
---|---|
compose.compiler | Compile time code generation and optimization for @Composable based on Kotlin Compiler Plugin |
compose.runtime | Provides NodeTree management, State management, etc., the basic runtime of declarative UI |
compose.ui | Basic UI capabilities related to Android devices, such as layout, measure, drawing, input, etc |
compose.foundation | Generic UI components, including containers such as Column, Row, and shapes |
compose.animation | Responsible for animation implementation and user experience improvement |
compose.material | Provide UI components that conform to Material Design standards |
Compose. Runtime and Compose.com Piler are the core components of a declarative UI.
Jake Wharton writes on his blog:
What this means is that Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type. Well a “tree of nodes” describes just about anything, and as a result Compose can target just about anything.
– jakewharton.com/a-jetpack-c…
Compose. Runtime provides NodeTree management and other basic capabilities, which are platform independent. On this basis, each platform only needs to implement UI rendering to form a complete declarative UI framework. Compose.com Piler, with compile-time optimizations, gives developers the ability to write simpler code to call Runtime.
NodeTree from Composable
“Compose, React, Flutter, the code is essentially a description of a tree.”
When state changes, the data start UI reconstructs the tree structure and refreshes the UI based on the NodeTree. Of course, for performance reasons, when NodeTree needs to be rebuilt, the frameworks will use different technologies such as VirtualDom and GapBuffer (or ttable) to update it “differently” to avoid a “full” rebuild. Compose. Runtime is responsible for creating and updating nodeTrees.
As above, React updates the DOM tree on the right based on the VDOM “differentials.”
Compose the NodeTree
In OOP languages, we usually describe a tree as follows:
fun TodoApp(items: List<TodoItem>): Node {
return Stack(Orientation.Vertical).apply {
for (item in items) {
children.add(Stack(Orientation.Horizontal).apply {
children.add(Text(if (item.completed) "x" else ""))
children.add(Text(item.title))
})
}
}
}
Copy the code
TodoApp returns a Node object that can be added by the parent Node, and loops to form a complete tree.
But OOP writing template code is too much, not concise, and lack of security. The return value Node becomes a handle that can be referenced or even modified. This breaks the principle of “immutability” in declarative UI. If the UI can be modified at will, the accuracy of the DIff algorithm cannot be guaranteed.
Therefore, to ensure UI immutability, we try to erase the return value Node:
fun Composer.TodoApp(items: List<TodoItem>) {
Stack(Orientation.Vertical) {
for (item in items) {
Stack(Orientation.Horizontal) {
Text(if (item.completed) "x" else "")
Text(item.title)
}
}
}
}
fun Composer.Stack(orientation:Int, content: Composer. () - >Unit) {
emit(StackNode(orientation)) {
content()
}
}
fun Composer.Text(a){... }Copy the code
Using the context provided by Composer, the created Node is emitted to the appropriate location on the tree.
interface Composer {
// add node as a child to the current Node, execute
// `content` with `node` as the current Node
fun emit(node: Node, content: () -> Unit = {})
}
Copy the code
The fact that Composer.stack () is a function with no return value makes NodeTree build from OOP to FP(functional programming).
Compose Compiler blessing
Compose.com Piler is intended to make FP writing easier by adding a @composable annotation. TodoApp does not have to be defined as an extension function to Composer, but the signature of TodoApp will be changed at compile time. Add the Composer parameter.
@Composable
fun TodoApp {
Stack {
for (item in items) {
Stack(Orientation.Horizontal){
Text(if (item.completed) "x" else "")
Text(item.title))
})
}
}
}
Copy the code
With the benefit of Compiler, we can use @Composable to write code efficiently. Language differences aside, Compose is much more comfortable to write than Flutter. However, no matter how different they are written, the degree of the root is still converted to the operation on the NodeTree
NodeTree Operations: Applier, ComposeNode, and Composition
Compose NodeTree management involves the work of Applier, Composition, and Compose Nodes:
Composition initiates the first Composition, fills the Slot Table through the Composalbe execution, and creates a NodeTree based on the Table. The rendering engine renders the UI based on Compose Nodes and updates the NodeTree via Applier whenever recomposition occurs. so
“A Composable is a process of creating a Node and building a NodeTree.”
Applier: Changes the node of NodeTree
As mentioned earlier, for performance reasons, NodeTree updates itself using a “differential” approach, which is implemented based on Applier. The Applier uses the Visitor mode to traverse the nodes in the tree, and each NodeTree operation requires an Applier.
Applier provides callbacks based on which we can customize the NodeTree:
interface Applier<N> {
val current: N // The node currently being processed
fun onBeginChanges(a) {}
fun onEndChanges(a) {}
fun down(node: N)
fun up(a)
fun insertTopDown(index: Int, instance: N) // Add node (top down)
fun insertBottomUp(index: Int, instance: N)// Add nodes (bottom up)
fun remove(index: Int, count: Int) // Delete the node
fun move(from: Int, to: Int, count: Int) // Move the node
fun clear(a)
}
Copy the code
Both insertTopDown and insertBottomUp are used to add nodes, and the different order of addition for different tree structures is helpful to improve performance. Reference: insertTopDown
InsertTopDown (top down) | insertBottomUp |
---|---|
We can implement a custom NodeApplier as follows:
class Node {
val children = mutableListOf<Node>()
}
class NodeApplier(node: Node) : AbstractApplier<Node>(node) {
override fun onClear(a) {}
override fun insertBottomUp(index: Int, instance: Node) {}
override fun insertTopDown(index: Int, instance: Node) {
current.children.add(index, instance) // `current` is set to the `Node` that we want to modify.
}
override fun move(from: Int, to: Int, count: Int) {
current.children.move(from, to, count)
}
override fun remove(index: Int, count: Int) {
current.children.remove(index, count)
}
}
Copy the code
Applier needs to be invoked in the process of composition/recomposition. Composition is initiated through a call to the Root Composable in composition, which in turn calls all the ComposalBes to form a NodeTree.
Composition: The starting point for the Composalbe execution
Fun Composition(applier: applier <*>, parent: CompositionContext) Creates the Composition object, passing parameters to applier and Recomposer
val composition = Composition(
applier = NodeApplier(node = Node()),
parent = Recomposer(Dispatchers.Main)
)
composition.setContent {
// Composable function calls
}
Copy the code
Recomposer is very important, and he is responsible for the recomposiiton of Compose. When a NodeTree is first created, it is associated with the state and listens for changes in the state. This association is created using a “snapshot system” for Recomposer. After regrouping, Recomposer completes changes to the NodeTree by calling Applier.
For more information about the “snapshot system” and how Recomposer works, please refer to:
- Compose.net.cn/principle/s…
- Compose.net.cn/principle/r…
Composition#setContent provides a container for subsequent Compodable calls:
interface Composition {
val hasInvalidations: Boolean
val isDisposed: Boolean
fun dispose(a)
fun setContent(content: @Composable() - >Unit)
}
Copy the code
ComposeNode: Create a UiNode and update it
Each Composable execution theoretically corresponds to the creation of a Node, but since NodeTree does not require a full rebuild, it is not necessary to create a new Node every time. Most Composalbe calls ComposeNode() to accept a Factory, creating nodes only when necessary.
Take the implementation of Layout as an example.
@Composable inline fun Layout(
content: @Composable() - >Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
ComposeNode<ComposeUiNode, Applier<Any>>(
factory = ComposeUiNode.Constructor,
update = {
set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
set(density, ComposeUiNode.SetDensity)
set(layoutDirection, ComposeUiNode.SetLayoutDirection)
},
skippableUpdate = materializerOf(modifier),
content = content
)
}
Copy the code
- Factory: Creates a factory for Node
- update: Accepts that receiver is
Updater<T>
Lambda, which updates the properties of the current Node - Content: Calls the sub-composable
The ComposeNode() implementation is very simple:
inline fun <T, reified E : Applier<*>> ComposeNode(
noinline factory: () -> T,
update: @DisallowComposableCalls Updater<T>. () - >Unit.noinline skippableUpdate: @Composable SkippableUpdater<T>. () - >Unit,
content: @Composable() - >Unit
) {
if (currentComposer.applier !is E) invalidApplier()
currentComposer.startNode()
if (currentComposer.inserting) {
currentComposer.createNode(factory)
} else {
currentComposer.useNode()
}
Updater<T>(currentComposer).update()
SkippableUpdater<T>(currentComposer).skippableUpdate()
currentComposer.startReplaceableGroup(0x7ab4aae9)// The real GroupId is determined at compile time
content()
currentComposer.endReplaceableGroup()
currentComposer.endNode()
}
Copy the code
During composition, child nodes are created recursively by updating SlotTable from the Composer context
During the update of SlotTable, you can use diff to determine whether operations such as add, update, or remove are required for a Node. StartNode, useNode, endNode, and so on are traversals of SlotTable.
About SlotTable (GapBuffer) is introduced, which can be reference articles: compose.net.cn/principle/g…
Diff results in SlotTable deal with changes to NodeTree structure through Applier’s callbacks; Changes to Node properties are handled by calling Updater
.update()
Jake Wharton’s experimental project Mosica
You can implement any set of declarative UI frameworks based on compose. Runtime. J God has an experimental project, Mosica, which shows this very well: github.com/JakeWharton…
fun main(a) = runMosaic {
var count by mutableStateOf(0)
setContent {
Text("The count is: $count")}for (i in 1.20.) {
delay(250)
count = i
}
}
Copy the code
Above is an example of a Counter in Mosica.
Mosica Composition
RunMosaic () creates Composition, Recomposer, and Applier
fun runMosaic(body: suspend MosaicScope. () - >Unit) = runBlocking {
/ /...
val job = Job(coroutineContext[Job])
val composeContext = coroutineContext + clock + job
val rootNode = BoxNode() // The root Node is Node
val recomposer = Recomposer(composeContext) //Recomposer
val composition = Composition(MosaicNodeApplier(rootNode), recomposer) //Composition
coroutineScope {
val scope = object : MosaicScope, CoroutineScope by this {
override fun setContent(content: @Composable() - >Unit) {
composition.setContent(content)/ / call @ Composable
hasFrameWaiters = true}}/ /...
val snapshotObserverHandle = Snapshot.registerGlobalWriteObserver(observer)
try {
scope.body()// setContent{} in CoroutineScope
} finally {
snapshotObserverHandle.dispose()
}
}
}
Copy the code
Next, in Composition’s setContent{}, call @Composable.
Mosaic Node
Take a look at @Composalbe in Mosaic and its corresponding Node
@Composable
private fun Box(flexDirection: YogaFlexDirection, children: @Composable() - >Unit) {
ComposeNode<BoxNode, MosaicNodeApplier>(
factory = ::BoxNode,
update = {
set(flexDirection) {
yoga.flexDirection = flexDirection
}
},
content = children,
)
}
Copy the code
@Composable
fun Text(
value: String,
color: Color? = null,
background: Color? = null,
style: TextStyle? = null.) {
ComposeNode<TextNode, MosaicNodeApplier>(::TextNode) {
set(value) {
this.value = value
}
set(color) {
this.foreground = color
}
set(background) {
this.background = background
}
set(style) {
this.style = style
}
}
}
Copy the code
The ComposeNode uses generics to associate the corresponding Node and Applier types
Both Box and Text internally use ComposeNode() to create the corresponding Node object. Box is the Composalbe of the container class, and child nodes are further created in Conent. Box and Text update Node properties in Updater
.update().
Look at the BoxNode:
internal class BoxNode : MosaicNode() {
val children = mutableListOf<MosaicNode>()
override fun renderTo(canvas: TextCanvas) {
for (child in children) {
val childYoga = child.yoga
val left = childYoga.layoutX.toInt()
val top = childYoga.layoutY.toInt()
val right = left + childYoga.layoutWidth.toInt() - 1
val bottom = top + childYoga.layoutHeight.toInt() - 1
child.renderTo(canvas[top..bottom, left..right])
}
}
override fun toString(a) = children.joinToString(prefix = "Box(", postfix = ")")}internal sealed class MosaicNode {
val yoga: YogaNode = YogaNodeFactory.create()
abstract fun renderTo(canvas: TextCanvas)
fun render(a): String {
val canvas = with(yoga) {
calculateLayout(UNDEFINED, UNDEFINED)
TextSurface(layoutWidth.toInt(), layoutHeight.toInt())
}
renderTo(canvas)
return canvas.toString()
}
}
Copy the code
BoxNode inherits from MosaicNode, and MosaicNode implements UI drawing through yoga in render(). RenderTo () is used to recursively draw child nodes in the Canvas, similar to the drawing logic of AndroidView.
In theory, we need to call the Node render() to draw the NodeTree at the first composition or recomposition. For simplicity, Mosica only calls the render() using the periodic polling method.
launch(context = composeContext) {
while (true) {
if (hasFrameWaiters) {
hasFrameWaiters = false
output.display(rootNode.render())
}
delay(50)}}// Reset content after state change of counter, render again after hasFrameWaiters update
coroutineScope {
val scope = object : MosaicScope, CoroutineScope by this {
override fun setContent(content: @Composable() - >Unit) {
composition.setContent(content)
hasFrameWaiters = true}}}Copy the code
MosaicNodeApplier
A final look at MosaicNodeApplier:
internal class MosaicNodeApplier(root: BoxNode) : AbstractApplier<MosaicNode>(root) {
override fun insertTopDown(index: Int, instance: MosaicNode) {
// Ignored, we insert bottom-up.
}
override fun insertBottomUp(index: Int, instance: MosaicNode) {
val boxNode = current as BoxNode
boxNode.children.add(index, instance)
boxNode.yoga.addChildAt(instance.yoga, index)
}
override fun remove(index: Int, count: Int) {
val boxNode = current as BoxNode
boxNode.children.remove(index, count)
repeat(count) {
boxNode.yoga.removeChildAt(index)
}
}
override fun move(from: Int, to: Int, count: Int) {
val boxNode = current as BoxNode
boxNode.children.move(from, to, count)
val yoga = boxNode.yoga
val newIndex = if (to > from) to - count else to
if (count == 1) {
val node = yoga.removeChildAt(from)
yoga.addChildAt(node, newIndex)
} else {
val nodes = Array(count) {
yoga.removeChildAt(from)
}
nodes.forEachIndexed { offset, node ->
yoga.addChildAt(node, newIndex + offset)
}
}
}
override fun onClear(a) {
val boxNode = root as BoxNode
// Remove in reverse to avoid internal list copies.
for (i in boxNode.yoga.childCount - 1 downTo 0) {
boxNode.yoga.removeChildAt(i)
}
}
}
Copy the code
MosaicNodeApplier implements add/move/remove for Node, which is eventually reflected in the operation of YogaNode, and the UI is refreshed by YogaNode
Declarative UI based on AndroidView
In the example shown in Moscia, we can use compose. Runtime to create a declarative UI framework based on Android’s native View.
LinearLayout & TextView Node
@Composable
fun TextView(
text: String,
onClick: () -> Unit= {}) {
val context = localContext.current
ComposeNode<TextView, ViewApplier>(
factory = {
TextView(context)
},
update = {
set(text) {
this.text = text
}
set(onClick) {
setOnClickListener { onClick() }
}
},
)
}
@Composable
fun LinearLayout(children: @Composable() - >Unit) {
val context = localContext.current
ComposeNode<LinearLayout, ViewApplier>(
factory = {
LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
},
update = {},
content = children,
)
}
Copy the code
ViewApplier
Only Add is implemented in ViewApplier
class ViewApplier(val view: FrameLayout) : AbstractApplier<View>(view) {
override fun onClear(a) {
(view as? ViewGroup)? .removeAllViews() }override fun insertBottomUp(index: Int, instance: View) {
(current as? ViewGroup)? .addView(instance, index) }override fun insertTopDown(index: Int, instance: View){}override fun move(from: Int, to: Int, count: Int) {
// NOT Supported
TODO()
}
override fun remove(index: Int, count: Int) {
(view as? ViewGroup)? .removeViews(index, count) } }Copy the code
Create a Composition
Create a Composable: AndroidViewApp
@Composable
private fun AndroidViewApp(a) {
var count by remember { mutableStateOf(1) }
LinearLayout {
TextView(
text = "This is the Android TextView!!",
)
repeat(count) {
TextView(
text = "Android View!! TextView:$it $count",
onClick = {
count++
}
)
}
}
}
Copy the code
And then we call AndroidViewApp in content
fun runApp(context: Context): FrameLayout {
val composer = Recomposer(Dispatchers.Main)
GlobalSnapshotManager.ensureStarted()
val mainScope = MainScope()
mainScope.launch(start = CoroutineStart.UNDISPATCHED) {
withContext(coroutineContext + DefaultMonotonicFrameClock) {
composer.runRecomposeAndApplyChanges()
}
}
mainScope.launch {
composer.state.collect {
println("composer:$it")}}val rootDocument = FrameLayout(context)
Composition(ViewApplier(rootDocument), composer).apply {
setContent {
CompositionLocalProvider(localContext provides context) {
AndroidViewApp()
}
}
}
return rootDocument
}
Copy the code
Effect display:
TL; DR
- Composable executes again when recomposition is triggered when State changes
- Composable uses diff in SlotTable to find the Node to be changed during execution
- Update the TreeNode through Applier and render the tree at the UI layer.
- Based on compose. Runtime, we can implement our own declarative UI