The Composable node tree 🌲
Learn how the Jetpack Compose node tree is built, updated, how changes to it are applied, the different types of nodes on it, and more 🔥
There is not much (if any) literature on the internet about how the composable node tree is represented, created and maintained. Let’s learn about it here.
The in-memory node tree
Compose keeps track of all the Composable calls that describe our UI by storing relevant information about them in an in-memory data structure called the slot table. It also builds up an in-memory representation of the node tree, that is updated with every recomposition. We are focusing on the later in this post.
This tree is frequently called the “node tree”, or “in-memory tree”, and it is represented with the Composition
class in the Compose runtime.
Every time a Composable function executes, it produces data to build or update the tree (Composition
).
The first time Composable functions execute (during the composition process), they build up the tree. Every time they re-execute (recomposition), they produce new data and update the tree.
Why to maintain an in-memory tree?
The Compose runtime is agnostic of the target platform. It only knows that a node tree exists and how to optimize it and traverse it, but any actions taken over the tree are delegated to client libraries like Compose UI.
On top of this, Compose UI is a multiplatform library (Desktop + Android), so while it is able to build and update the tree, it must be an in-memory representation of it that also remains agnostic of the platform, so it can be translated to the specific trees represented from any of the two supported platforms.
Creating a Composition
A Composition
is created every time we call ComposeView.setContent.
Once created, Composition.setContent(content)
is called on it, which makes all the Composable calls in the content
lambda execute within this scope, and effectively become part of this Composition:
Since the Composition
is platform agnostic (part of the Compose runtime), it needs to be bridged to the Android platform to be able to draw the node tree on screen, and wire keyboard and accessibility events, among other things. This is done by decorating the Composition (wrapping it). See WrappedComposition
in the previous snippet.
This diagram summarizes the ComposeView.setContent
call, which creates the WrappedComposition
:
The ViewCompositionStrategy determines when to dispose the Composition. You can find all the strategies available here.
Besides creating the Composition
, a few more things take place.
A couple suspend functions are executed within LaunchedEffect
to start listening for keyboard and accessibility events, so those can be wired to the corresponding Android primitives / services.
Composition locals for relevant Android primitives are exposed down the Composable tree, so things like Context
or Density
can be accessed from any level.
Finally, it starts observing snapshot state writes, and sets the content
.
The list of changes
We learned that Composition is built and updated when Composable functions execute / re-execute. It is actually a bit different than that. When one or multiple Composable functions are executed, what they really produce is a list of changes to the tree. These changes are lambdas to insert, remove, replace, move nodes around (reorder children), or save or update values into the slot table (e.g: remember
calls), among other options.
Since the changes are encoded as lambdas, they are deferred. Once the composition or recomposition process finishes, it is time to apply all those changes to the tree, so they become effectively visible on screen.
Applying the changes
The runtime uses the Recomposer
to drive the process of initial composition and later recompositions. Every time those processes complete, the Recomposer
tells the Composition
to apply the pending changes to the tree.
The entity in charge of applying the changes is the Applier
, which we are passing an implementation for in the ComposeView.setContent
call (see the initial snippet again).
To do this, the Composition basically iterates over the list of changes and executes them one by one. When the change lambdas are executed, they get a reference to the applier, so it can be used to make the changes effective and therefore update the tree (nodes inserted, removed, replaced, moved…):
All the mentioned actions to finally update the tree are actually delegated by the applier to the nodes themselves, since the runtime is agnostic of the platform, and each client library (like Compose UI) can provide different types of nodes.
On top of this, the change lambdas also get a reference to the slot table writers or readers, since there is a chance they need to check and/or update the current state of the composition. — I’ll likely write more about this in the future.
Node types in Android
If we assume that we only have layouts in our UI tree, what we get built is a LayoutNode
tree. The LayoutNode
is the representation of a piece of UI in-memory. It knows about its children, their order, and how to measure and place itself and its children.
Whenever the tree is updated (e.g: a new child is attached), the ComposeView
that holds the tree (at the root level) is invalidated. Since the ComposeView
is an Android ViewGroup
, that triggers its dispatchDraw
call again, and its custom implementation will only remeasure / relayout / redraw the affected nodes.
But there is actually more than one node type. Compose UI provides two types of nodes: LayoutNode
and VNode
. We can find both types within a node tree.
VNode
is the representation of a vector node in-memory, since vectors can also be represented as a tree of nodes.
When our UI contains multiple types of nodes, what it really happens is that one or multiple child compositions (subcompositions) are created and linked together as a tree. Each composition/subcomposition fixes a node type. That allows to introduce different node types for the corresponding subtrees. Here is an example for a UI composed of a few layouts and a vector (used from an Image
Composable) 👇
Hooking compositions and subcompositions as a tree allows to propagate state changes and CompositionLocals across multiple compositions as if they were a single one.
👨🏫 The exclusive online training
If you are interested on this and want me to expand on the described topics, join us in the online training 🚀
Master Jetpack Compose and learn how to work efficiently with it by going through the weekly stages of this course in community. Join others and interact with them and the trainer while building a complete Android app from scratch using Compose.
📖 Jetpack Compose internals
If you liked this post consider acquiring the Jetpack Compose internals book, where I go over all this in more detail.
🐦 More on Twitter
You can follow me on Twitter for more. I post about Jetpack Compose and other Android topics all the time.