The Three Phases: Composition, Layout, and Drawing
The Three Phases: Composition, Layout, and Drawing
Jetpack Compose transforms declarative UI code into pixels on screen through a pipeline of three distinct phases: Composition, Layout, and Drawing. When you change a state variable, Compose doesn't redraw everything, it determines which phases need to run and executes only the necessary work. A change that only affects drawing can skip composition and layout entirely, while a structural change might require all three phases. Understanding which phase your code triggers helps you write more efficient Compose applications.
In this article, you'll explore how the three phases work internally, examining how the Composition phase builds and updates the UI tree through the SlotTable and Composer, how the Layout phase measures and positions nodes through LayoutNode and Constraints propagation, how the Drawing phase renders content through DrawScope and GraphicsLayer, and how invalidation propagates through the system. This isn't a guide on using Compose, it's an exploration of the execution pipeline that transforms your composable functions into rendered UI.
The execution pipeline: From state to pixels
When Compose needs to display UI, it executes three phases in strict order. Composition builds the UI tree by running your composable functions and recording what needs to be displayed. Layout takes that tree and determines the size and position of every element. Drawing takes the positioned elements and renders them to the screen. Each phase depends on the previous phase completing, but not every state change requires all three phases.
Consider what happens when you animate an element's opacity. In a naive implementation, changing opacity would trigger composition (rebuild the tree), layout (remeasure and reposition), and drawing (render). But opacity doesn't affect tree structure or element positions, it's purely a visual property. Compose optimizes this by allowing opacity changes in GraphicsLayer to trigger only the drawing phase, skipping composition and layout entirely. This optimization is only possible because of the phase separation.
The phase model also explains why certain patterns are problematic. Reading layout coordinates during composition forces the system to complete layout before finishing composition, breaking the normal phase ordering. Understanding the phases helps you write code that works with the system rather than against it.
Composition phase: Building the UI tree
The Composition phase is where your composable functions execute. The Composer walks through your code, tracks what you've called, compares it to the previous composition, and records changes. This phase doesn't produce pixels, it produces a tree of nodes that the subsequent phases will process.
The Composer's role
The Composer is the runtime engine that executes composable functions. Every composable function receives an implicit $composer parameter injected by the compiler:
// What you write
@Composable
fun Greeting(name: String) {
Text("Hello, $name")
}
// What the compiler generates (simplified)
fun Greeting(name: String, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(1234)
if ($composer.changed(name) || !$composer.skipping) {
Text("Hello, $name", $composer, 0)
} else {
$composer.skipToGroupEnd()
}
$composer.endRestartGroup()?.updateScope { $composer ->
Greeting(name, $composer, $changed or 1)
}
}
The Composer serves three functions. First, it records positional information, tracking the results of remember lambdas, composable function parameters, and the structure of calls. Second, it detects changes by comparing current values against previous composition state. Third, it incrementally evaluates composition by only recomposing functions whose inputs have changed.
The SlotTable: Persistent memory
Composition state lives in the SlotTable, a data structure that stores the UI tree in a flattened format optimized for incremental updates. The SlotTable uses two arrays: one for group metadata and one for slot values.
Each group in the table contains:
| Key | Flags | Nodes | Size | Parent | Slots |
|------|--------|-------|-------|--------|-------|
| 1234 | <none> | 0 | 3 | -1 | 0 |
| 4567 | <none> | 0 | 1 | 0 | 0 |
The Key is a compiler-generated integer that identifies the call site. Flags indicate the node type. Nodes counts how many UI nodes this group produces. Size tracks the total number of child groups. Parent points to the containing group. Slots references stored values like remember results.
When recomposition occurs, the Composer walks the SlotTable, comparing the current execution against stored state. If a group hasn't changed, the Composer can skip it entirely using skipToGroupEnd(). This is how Compose achieves efficient incremental updates, unchanged portions of the tree are simply skipped.
Triggers for composition
Composition runs when the system detects that UI might need to change. The primary triggers are state changes, where reading a mutableStateOf value during composition creates a dependency, and when that state changes, the reading scope is invalidated. Structural changes occur when conditional content appears or disappears, requiring the tree to be modified. Explicit invalidation happens when calling invalidate() on a recompose scope directly.
The Recomposer coordinates these invalidations:
private fun deriveStateLocked(): CancellableContinuation<Unit>? {
val newState = when {
compositionInvalidations.isNotEmpty() ||
snapshotInvalidations.isNotEmpty() -> State.PendingWork
else -> State.Idle
}
}
When invalidations exist, the Recomposer schedules composition work. This batching is important, multiple state changes in a single frame result in a single composition pass, not multiple passes.
Skipping vs restarting
The Composer distinguishes between skipping and restarting. Skipping occurs when a composable's inputs haven't changed and it can be bypassed entirely. The generated code checks $composer.skipping and calls skipToGroupEnd() when safe:
if ($composer.changed(person) || !$composer.skipping) {
// Execute content
} else {
$composer.skipToGroupEnd()
}
Restarting is more nuanced. Functions wrapped with startRestartGroup and endRestartGroup can be individually recomposed when their observed state changes. The endRestartGroup() call returns a scope that captures how to re-invoke the function:
$composer.endRestartGroup()?.updateScope { $composer ->
B(person, $composer, $changed or 1)
}
This restart lambda is what enables selective recomposition, when state changes, only the scopes that read that state need to re-execute, not the entire composition.
Layout phase: Measuring and positioning
Once composition completes, Compose has a tree of LayoutNodes representing the UI structure. The Layout phase determines where each node should appear on screen by measuring sizes and calculating positions.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor