Jetpack Compose Phases

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:

  1. Composition: What UI to show. Compose runs composable functions and creates a description of your UI.
  2. 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.
  3. 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.

You can safely assume that these three phases happen virtually for every frame, but for the sake of 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.

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.