There isn’t much literature about the internal workings of Compose, the amazing declarative framework which allows you to build not only Android apps but also cross-platform applications. In this article, I would like to explain the core concepts of how Compose works internally. This is going to be a very theoretical exploration, but I’ve created flowcharts to help build a mental model. I’ve skimmed over some parts that I thought weren’t crucial for understanding Compose’s internal mechanics.
Background
Compose was heavily inspired by React and was initially built as an extension of the old Android view system, where you can replicate the XML structure in Kotlin. However, while it initially worked using the old view system, JetBrains didn’t want to integrate that capability into the Kotlin language. This led Google to develop an alternative using function syntax.
https://twitter.com/AndroidDev/status/1363207926580711430
Compose isn’t tied to Android or any specific UI toolkit; it is a general-purpose solution, as long as the problem can be represented in or as a tree. The core components of Compose are:
When most people (Android developers) refer to Jetpack Compose, they’re usually talking about either Compose UI or all three components together. Compose UI is the UI toolkit specifically designed for Android by Google. However, JetBrains has also developed Compose toolkits for other platforms like Web and iOS. If you’re curious about how the common name ‘Compose’ can lead to ambiguity regarding which specific components are being referred to, Jake’s article1 provides a great explanation.
Now that I’ve covered the background, let’s dive into the core components of Compose, starting with the Compose Compiler.
Compose Compiler
Before we get into the Compose Compiler itself, let’s first understand what a compiler plugin is. A Compiler plugin interacts with the Intermediate Representation (IR) when the kotlin source code is halfway compiled. At this stage, compiler plugins step in to modify the code as needed.
The Compose Compiler integrates with the Kotlin compiler through the ComponentRegistrar
interface, which acts as the entry point for any compiler plugin. It registers a series of extensions that simplify using the Compose library.
The Compose Compiler analyses the source code during compile time to ensure everything is in place for the next steps. This involves checks like verifying Kotlin compiler dependencies since the Composer Compiler depends on specific Kotlin versions.
When the Compose Compiler identifies any function annotated with @Composable
, it rewrites it slightly to enable the desired runtime behaviour. This transformation process, where higher-level constructs like lambdas and inline functions are converted into, lower-level representations, is known as “Lowering.” The Compose Compiler modifies elements in the IR tree during this phase to make them compatible with the runtime.
Here’s a simplified flowchart I created to visualise the key steps of the Compose Compiler.
In summary, the Compose Compiler modifies the source code during the IR phase by adding and altering metadata, which ultimately supports the functionality of the Compose Runtime. Afterwards, the IR is compiled into the native binaries for JVM, JS, LLVM (for iOS), or WASM, allowing Compose to be multi-platform. The IR modifications made by the Compose Compiler ease the work of the Compose Runtime. While I won’t go into the details of every lowering, I’ll briefly touch on a few important transformations.
Let’s now explore some of the key lowerings and transformations done by the Compose Compiler.
Infer Class Stability
Compose infers the stability of classes to help the runtime with smart recompositions. It allows the runtime to skip recomposing @Composable
functions whose inputs haven’t changed. The Compose Compiler infers stability based on certain criteria2, and this algorithm is constantly evolving.
Primitive types are stable by default in Compose. For custom types such as data classes, you need to annotate them with @Stable
or @Immutable
if they should be treated as stable.
Enabling Live Literals
Live literals enable the Compose tooling to generate live previews of @Composables
functions without needing recompilation.
“This transformation is intended to improve developer experience and should never be enabled in a release build as it will significantly slow down performance-conscious code”
Compose Lambda Memoization
In this step, the Compose Compiler teaches the runtime how to handle the lambdas passed to the @Composable
functions. There are two kinds of lambdas: composable and non-composable.
If a non-composable lambda passed to a @Composable
function doesn’t capture any value at the call site, the Kotlin compiler optimises it by modelling it as a singleton. However, if a non-composable lambda captures a value, the Compose Compiler optimises it by wrapping it in a remember
call.
For composable lambdas, the Compose Compiler memoizes them by storing and retrieving them from a slot table, allowing the runtime to efficiently recompose only the parts of the UI tree that need updating. This technique, originally called “donut-hole skipping”3 because it allows for a lambda to be updated “high” in the tree, and for Compose to only need to recompose at the very “low” portion of the tree where this value is actually read.
Inject the composer
The Compose compiler injects a special $composer
parameter into all @Composable
functions and passes it to their sub-composables. The compiler wraps the body of each @Composable
function with $composer.start(key)
and $composer.end()
calls, where key
represents an arbitrary integer that serves as a hash of the function’s name and call site.
The Compose Compiler performs additional optimisations & lowerings on the sources by inserting metadata and modifying the source code. These changes will ultimately help the Compose Runtime in its operations.
Having examined the role of the Compose Compiler, we can now turn our attention to the heart of Compose: the Compose Runtime.
Compose Runtime
The Compose Runtime4 is the heart of Compose. It manages state, handles smart recomposition, and ensures efficient updates to the UI. The $composer
injected by the compiler connects composable functions to the Compose Runtime.
To help you understand how the runtime works, here’s a simplified flowchart:
The Compose Runtime’s core data structures are the Slot Table and the list of changes. A Slot Table5 is based on the concept of a data structure called Gap Buffer (commonly used in text editors). As composables emit, the Slot Table is populated with their state.
When a composable is executed or emits, the runtime stores the current state of the composition in the Slot Table, which records details like the location of each composable, its parameters, remembered values, and CompositionLocals
. Think of the slot table as a method trace of the composition process.
The slot table holds the record of what occurred during composition, which functions were called, and what parameters were used. The runtime uses this information to generate a list of changes based on the current information available in the slot table. This list is what makes the actual changes to the tree.
This list of changes is passed to an interface called Applier
, which applies these changes to the node tree. The Applier
is platform agnostic, making it possible for any UI toolkit to work with the Compose Runtime.
Once the Applier builds the tree, the Compose UI toolkit materialises the nodes into actual displayed on the screen. The runtime is also responsible for tracking recomposition invalidations and ensuring updates are applied efficiently.
Now that we’ve covered the Compose Runtime, let’s look at how Compose UI builds on top of this foundation.
Compose UI
Compose UI is a client library for the Compose Runtime. In this article, I’m focusing on Compose UI for Android by Google, but there are also client libraries for other platforms like Web and iOS, created by JetBrains.
Compose UI is like the bridge between your code and what you see on the screen. For Android, we use a special type of node called a LayoutNode
to handle all the layout, measuring, and drawing of your components.
Composing the Nodes
So, what happens when you call setContent
in your Compose code? Well, that’s where the magic begins! The Compose Runtime kicks off by building a tree of nodes—basically little chunks of UI that describe how your app should look and behave. Each part of your UI (like a button or a text field) gets its own node, and all of these nodes together form a tree.
In the Android world, each node is represented by a LayoutNode
. Think of a LayoutNode
as a blueprint for a particular UI component. It knows how big it needs to be, where it should be positioned, and how it should respond when things change (like when the user taps a button or scrolls a list).
Composing is the process of creating this tree of nodes. Compose will either create new nodes or reuse existing ones if nothing has changed. These nodes are stored in a slot table and basically serve as the backbone for your app’s UI.
Measuring the Nodes
Once our nodes are all set up, the next step is figuring out how much space they need. This is the measuring phase, where each node takes a look at its content and constraints (like available width and height) and decides how big it should be.
Each LayoutNode
comes with a MeasurePolicy
—kind of like a rulebook that tells it how to measure itself. For example, a Text
composable will look at things like the font size, the text itself, and any padding or margins to figure out how much space it needs.
The measuring happens from the top (root node) down to the smallest child nodes. A parent will ask its children how big they want to be, and once everyone reports back, the parent calculates its own size based on the children.
Placing the Nodes
Now that we know how big each node should be, it’s time to figure out where to put them. This is the placement phase, where each node is assigned a spot on the screen.
A LayoutNode
has something called a Placeable
which handles its position. Think of it like telling each UI element, “You, go here, and you, go there.” The Placeable
figures out the exact x and y coordinates based on things like alignment, padding, and other layout rules.
Placement happens in the same top-down fashion. Each parent node places its children based on their sizes and the space available to the parent.
For example, if you have a Column
composable, it’ll stack its children vertically, one below the other, placing each one based on the height they reported during the measuring phase.
Drawing the Nodes
Finally, we get to the fun part—actually drawing the UI on the screen! Once the nodes are measured and placed, Compose knows where and how big everything should be, so it can start drawing.
Each LayoutNode
creates a DrawNode
(yes, more nodes!) that knows how to render the actual content. For instance, if you have a Text
composable, its DrawNode
will take care of painting the text, applying fonts, colours, and all that good stuff.
On Android, we use a Canvas
to handle the drawing. The DrawNode
will generate drawing commands, and the Canvas
will render them on the screen.
This happens from the leaf nodes (the smallest components) back up to the root, ensuring that everything is drawn in the right order.
So, to break it down:
- Composition: The tree of nodes is built by running your Composable functions.
- Measuring: Each node figures out how much space it needs.
- Placement: The nodes are positioned on the screen.
- Drawing: The nodes are drawn using Android’s Canvas.
So there you have it—an inside look at how Jetpack Compose works under the hood! By breaking down the Compose Compiler, Compose Runtime, and Compose UI, we can see how each part plays a crucial role in making Compose so powerful and flexible. While it might seem like a lot to digest at first, the core idea is pretty simple: Compose builds a tree, figures out what changed, and updates just those parts. It’s all about efficiency.
Understanding these internals can really help you take full advantage of Compose, especially when optimizing performance or debugging complex UI issues.
The beauty of Compose is that it scales—whether you’re building small apps or complex, multi-platform solutions, it handles everything from state management to UI rendering with ease. And honestly, once you get comfortable with the framework, there’s no going back!
If you’re feeling adventurous, dive deeper into the topics we skimmed over—there’s so much more to explore, from stability inference to the Slot Table’s inner workings. But for now, I hope this article gave you a solid mental model of how Compose works.
Keep experimenting, keep building, and most importantly, have fun with it!
-
Jorge Castillo the author of the book Jetpack Compose Internals recommends reading library tests for the
ClassStabilityTransformTests
to understand how compose infers stability. ↩ -
Learn more about donut-hole skipping in this article by Vinay Gaba ↩
-
Documentation for Compose Runtime ↩
-
Leland Richardson, a software developer who contributed to building the Compose Runtime, wrote an article explaining how the slot table functions in the Compose Runtime. Most of the literature or articles available online that discuss the internal workings of Compose are based on his article. ↩