Like most other UI toolkits, Compose renders a frame through several distinct phases. If we look at the Android View system, it has three main phases: measure, layout, and drawing. Compose is very similar but has an important additional phase called composition at the start.
Composition is described across our Compose docs, including Thinking in Compose and State and Jetpack Compose.
The three phases of a frame
Compose has three main phases:
- Composition: What UI to show. Compose runs composable functions and creates a description of your UI.
- Layout: Where to place UI. This phase consists of two steps: measurement and placement. Layout elements measure and place themselves and any child elements in 2D coordinates, for each node in the layout tree.
- Drawing: How it renders. UI elements draw into a Canvas, usually a device screen.
The order of these phases is generally the same, allowing data to flow in one
direction from composition to layout to drawing to produce a frame (also known
as unidirectional data flow).
BoxWithConstraints
and
LazyColumn
and LazyRow
are notable
exceptions, where the composition of its children depends on the parent's layout
phase.
Conceptually, each of these phases happens for every frame; however to optimize performance, Compose avoids repeating work that would compute the same results from the same inputs in all of these phases. Compose skips running a composable function if it can reuse a former result, and Compose UI doesn't re-layout or re-draw the entire tree if it doesn't have to. Compose performs only the minimum amount of work required to update the UI. This optimization is possible because Compose tracks state reads within the different phases.
Understand the phases
This section describes how the three Compose phases are executed for composables in greater detail.
Composition
In the composition phase, the Compose runtime executes composable functions and outputs a tree structure that represents your UI. This UI tree consists of layout nodes that contain all the information needed for the next phases, as shown in the following video:
Figure 2. The tree representing your UI that is created in the composition phase.
A subsection of the code and UI tree looks like the following:
In these examples, each composable function in the code maps to a single layout node in the UI tree. In more complex examples, composables can contain logic and control flow, and produce a different tree given different states.
Layout
In the layout phase, Compose uses the UI tree produced in the composition phase as input. The collection of layout nodes contain all the information needed to decide on each node's size and location in 2D space.
Figure 4. The measurement and placement of each layout node in the UI tree during the layout phase.
During the layout phase, the tree is traversed using the following three step algorithm:
- Measure children: A node measures its children if any exist.
- Decide own size: Based on these measurements, a node decides on its own size.
- Place children: Each child node is placed relative to a node's own position.
At the end of this phase, each layout node has:
- An assigned width and height
- An x, y coordinate where it should be drawn
Recall the UI tree from the previous section:
For this tree, the algorithm works as follows:
- The
Row
measures its children,Image
andColumn
. - The
Image
is measured. It doesn't have any children, so it decides its own size and reports the size back to theRow
. - The
Column
is measured next. It measures its own children (twoText
composables) first. - The first
Text
is measured. It doesn't have any children so it decides its own size and reports its size back to theColumn
.- The second
Text
is measured. It doesn't have any children so it decides its own size and reports it back to theColumn
.
- The second
- The
Column
uses the child measurements to decide its own size. It uses the maximum child width and the sum of the height of its children. - The
Column
places its children relative to itself, putting them beneath each other vertically. - The
Row
uses the child measurements to decide its own size. It uses the maximum child height and the sum of the widths of its children. It then places its children.
Note that each node was visited only once. The Compose runtime requires only one pass through the UI tree to measure and place all the nodes, which improves performance. When the number of nodes in the tree increases, the time spent traversing it increases in a linear fashion. In contrast, if each node was visited multiple times, the traversal time increases exponentially.
Drawing
In the drawing phase, the tree is traversed again from top to bottom, and each node draws itself on the screen in turn.
Figure 5. The drawing phase draws the pixels on the screen.
Using the previous example, the tree content is drawn in the following way:
- The
Row
draws any content it might have, such as a background color. - The
Image
draws itself. - The
Column
draws itself. - The first and second
Text
draw themselves, respectively.
Figure 6. A UI tree and its drawn representation.
State reads
When you read the value of a snapshot state during one of the phases listed above, Compose automatically tracks what it was doing when the value was read. This tracking allows Compose to re-execute the reader when the state value changes, and is the basis of state observability in Compose.
State is commonly created using mutableStateOf()
and then accessed through one
of two ways: by directly accessing the value
property, or alternatively by
using a Kotlin property delegate. You can read more about them in State in
composables. For the purposes of
this guide, a "state read" refers to either of those equivalent access
methods.
// State read without property delegate. val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) } Text( text = "Hello", modifier = Modifier.padding(paddingState.value) )
// State read with property delegate. var padding: Dp by remember { mutableStateOf(8.dp) } Text( text = "Hello", modifier = Modifier.padding(padding) )
Under the hood of the property
delegate,
"getter" and "setter" functions are used to access and update the State’s
value
. These getter and setter functions are only invoked when you reference
the property as a value, and not when it is created, which is why the two ways
above are equivalent.
Each block of code that can be re-executed when a read state changes is a restart scope. Compose keeps track of state value changes and restart scopes in different phases.
Phased state reads
As mentioned above, there are three main phases in Compose, and Compose tracks what state is read within each of them. This allows Compose to notify only the specific phases that need to perform work for each affected element of your UI.
Let's go through each phase and describe what happens when a state value is read within it.
Phase 1: Composition
State reads within a @Composable
function or lambda block affect composition
and potentially the subsequent phases. When the state value changes, the
recomposer schedules reruns of all the composable functions which read that
state value. Note that the runtime may decide to skip some or all of the
composable functions if the inputs haven't changed. See Skipping if the inputs
haven't changed for more information.
Depending on the result of composition, Compose UI runs the layout and drawing phases. It might skip these phases if the content remains the same and the size and the layout won't change.
var padding by remember { mutableStateOf(8.dp) } Text( text = "Hello", // The `padding` state is read in the composition phase // when the modifier is constructed. // Changes in `padding` will invoke recomposition. modifier = Modifier.padding(padding) )
Phase 2: Layout
The layout phase consists of two steps: measurement and placement. The
measurement step runs the measure lambda passed to the Layout
composable, the
MeasureScope.measure
method of the LayoutModifier
interface, and so on. The
placement step runs the placement block of the layout
function, the lambda
block of Modifier.offset { … }
, and so on.
State reads during each of these steps affect the layout and potentially the drawing phase. When the state value changes, Compose UI schedules the layout phase. It also runs the drawing phase if size or position has changed.
To be more precise, the measurement step and the placement step have separate restart scopes, meaning that state reads in the placement step don't re-invoke the measurement step before that. However, these two steps are often intertwined, so a state read in the placement step can affect other restart scopes that belong to the measurement step.
var offsetX by remember { mutableStateOf(8.dp) } Text( text = "Hello", modifier = Modifier.offset { // The `offsetX` state is read in the placement step // of the layout phase when the offset is calculated. // Changes in `offsetX` restart the layout. IntOffset(offsetX.roundToPx(), 0) } )
Phase 3: Drawing
State reads during drawing code affect the drawing phase. Common examples
include Canvas()
, Modifier.drawBehind
, and Modifier.drawWithContent
. When
the state value changes, Compose UI runs only the draw phase.
var color by remember { mutableStateOf(Color.Red) } Canvas(modifier = modifier) { // The `color` state is read in the drawing phase // when the canvas is rendered. // Changes in `color` restart the drawing. drawRect(color) }
Optimizing state reads
As Compose performs localized state read tracking, we can minimize the amount of work performed by reading each state in an appropriate phase.
Let’s take a look at an example. Here we have an Image()
which uses the offset
modifier to offset its final layout position, resulting in a parallax effect as
the user scrolls.
Box { val listState = rememberLazyListState() Image( // ... // Non-optimal implementation! Modifier.offset( with(LocalDensity.current) { // State read of firstVisibleItemScrollOffset in composition (listState.firstVisibleItemScrollOffset / 2).toDp() } ) ) LazyColumn(state = listState) { // ... } }
This code works, but results in nonoptimal performance. As written, the code
reads the value of the firstVisibleItemScrollOffset
state and passes it to
the
Modifier.offset(offset: Dp)
function. As the user scrolls the firstVisibleItemScrollOffset
value will
change. As we know, Compose tracks any state reads so that it can restart
(re-invoke) the reading code, which in our example is the content of the
Box
.
This is an example of state being read within the composition phase. This is not necessarily a bad thing at all, and in fact is the basis of recomposition, allowing data changes to emit new UI.
In this example though it is nonoptimal, because every scroll event will result in the entire composable content being reevaluated, and then also measured, laid out and finally drawn. We’re triggering the Compose phase on every scroll even though what we are showing hasn’t changed, only where it is shown. We can optimize our state read to only re-trigger the layout phase.
There is another version of the offset modifier available:
Modifier.offset(offset: Density.() -> IntOffset)
.
This version takes a lambda parameter, where the resulting offset is returned by the lambda block. Let’s update our code to use it:
Box { val listState = rememberLazyListState() Image( // ... Modifier.offset { // State read of firstVisibleItemScrollOffset in Layout IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2) } ) LazyColumn(state = listState) { // ... } }
So why is this more performant? The lambda block we provide to the modifier is
invoked during the layout phase (specifically, during the layout phase's
placement step), meaning that our firstVisibleItemScrollOffset
state is no
longer read during composition. Because Compose tracks when state is read,
this change means that if the firstVisibleItemScrollOffset
value changes,
Compose only has to restart the layout and drawing phases.
This example relies on the different offset modifiers to be able to optimize the resulting code, but the general idea is true: try to localize state reads to the lowest possible phase, enabling Compose to perform the minimum amount of work.
Of course, it is often absolutely necessary to read states in the composition phase. Even so, there are cases where we can minimize the number of recompositions by filtering state changes. For more information about this, see derivedStateOf: convert one or multiple state objects into another state.
Recomposition loop (cyclic phase dependency)
Earlier we mentioned that the phases of Compose are always invoked in the same order, and that there is no way to go backwards while in the same frame. However, that doesn’t prohibit apps getting into composition loops across different frames. Consider this example:
Box { var imageHeightPx by remember { mutableStateOf(0) } Image( painter = painterResource(R.drawable.rectangle), contentDescription = "I'm above the text", modifier = Modifier .fillMaxWidth() .onSizeChanged { size -> // Don't do this imageHeightPx = size.height } ) Text( text = "I'm below the image", modifier = Modifier.padding( top = with(LocalDensity.current) { imageHeightPx.toDp() } ) ) }
Here we have (badly) implemented a vertical column, with the image at the top,
and then the text below it. We’re using Modifier.onSizeChanged()
to know the
resolved size of the image, and then using Modifier.padding()
on the text to
shift it down. The unnatural conversion from Px
back to Dp
already
indicates that the code has some issue.
The issue with this example is that we don’t arrive at the "final" layout within a single frame. The code relies on multiple frames happening, which performs unnecessary work, and results in UI jumping around on screen for the user.
Let’s step through each frame to see what is happening:
At the composition phase of the first frame, imageHeightPx
has a value of 0,
and the text is provided with Modifier.padding(top = 0)
. Then, the layout
phase follows, and the callback for the onSizeChanged
modifier is called.
This is when the imageHeightPx
is updated to the actual height of the image.
Compose schedules recomposition for the next frame. At the drawing phase, the
text is rendered with the padding of 0 since the value change is not reflected
yet.
Compose then starts the second frame scheduled by the value change of
imageHeightPx
. The state is read in the Box content block, and it is invoked
in the composition phase. This time, the text is provided with a padding
matching the image height. At the layout phase, the code does set the value of
imageHeightPx
again, but no recomposition is scheduled since the value
remains the same.
In the end, we get the desired padding on the text, but it is nonoptimal to spend an extra frame to pass the padding value back to a different phase and will result in producing a frame with overlapping content.
This example may seem contrived, but be careful of this general pattern:
Modifier.onSizeChanged()
,onGloballyPositioned()
, or some other layout operations- Update some state
- Use that state as input to a layout modifier (
padding()
,height()
, or similar) - Potentially repeat
The fix for the sample above is to use the proper layout primitives. The example
above can be implemented with a simple Column()
, but you may have a more
complex example which requires something custom, which will require writing a
custom layout. See the Custom layouts guide
for more information.
The general principle here is to have a single source of truth for multiple UI elements that should be measured and placed with regards to one another. Using a proper layout primitive or creating a custom layout means that the minimal shared parent serves as the source of truth that can coordinate the relation between multiple elements. Introducing a dynamic state breaks this principle.
Recommended for you
- Note: link text is displayed when JavaScript is off
- State and Jetpack Compose
- Lists and grids
- Kotlin for Jetpack Compose