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 isUpdater<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