In this post, I wanted to show you how I looked into a performance issue in Jetsnack and how I went about debugging and fixing it in Jetpack Compose. This post is available as a video as well if you prefer.

Let’s get started by looking at the Jetsnack sample.

The details screen has a fancy collapsing toolbar effect, where as we scroll up the content moves up and resizes and as we scroll back down the content returns to how it started.

Jetsnack details screen — Collapsing toolbar

We’ve had reports that on lower-end devices this can appear quite janky as it scrolls. So, let’s look into why that could be.

Compose phases recap

Before we start debugging though, let’s quickly go over some required knowledge for debugging this issue. Remember that Compose has 3 phases:

  1. Composition determines what to show by building a tree of Composables.
  2. Layout takes that tree and works out where on screen they will be shown.
  3. Drawing then draws the composables to the screen.
The three phases of Compose

Here is the cool part: Compose can skip a phase entirely if no state has changed in it. So there should be no need to recompose just to relayout the screen. If we can avoid changing our composition tree, Compose will skip the composition phase entirely and this can lead to performance gains.

This is why our performance documentation states, “prefer lambda modifiers when using frequently changing state”. Using lambdas is what allows work to be deferred to a later phase and for composition to be skipped.

Using lambda modifiers

Why does using a lambda modifier mean we can skip composition? Let’s return to the Composition tree and see.

Lambdas changing the composition tree

The composition tree is also built up of any modifiers that are applied to the composables.

Modifiers are effectively immutable objects. When the translation changes as the user scrolls up, the modifier is reconstructed, the old one is removed and the new one is added to the composition tree. This happens every time the offset changes. Because the Composition tree has changed, recomposition occurs.

So remember, you shouldn’t have to recompose just to relayout a screen, especially while scrolling, which will lead to janky frames. Whenever you see unnecessary recomposition, think about how you could move the work to a later phase.

Fixing Jetsnack’s scrolling performance issue

Now that we have gone over the theory, we can dive into the actual fix in Jetsnack.

Using the Layout Inspector in Android Studio, we can see the recomposition and skip counts of composable functions. If we go to the details page of Jetsnack and scroll it up and down, it can be seen that the Title composable is recomposing a lot. Most likely on every frame 😨

Note: If you upgrade to Android Studio Electric Eel, you can also see highlights when a composable recomposes in the Layout Inspector.

Layout inspector showing recomposition happening

If we have a look at the SnackDetail composable:

 /* Copyright 2022 Google LLC.
  SPDX-License-Identifier: Apache-2.0 */

@Composable
fun SnackDetail(
    snackId: Long,
    upPress: () -> Unit
) {
    val snack = remember(snackId) { SnackRepo.getSnack(snackId) }
    val related = remember(snackId) { SnackRepo.getRelated(snackId) }

    Box(Modifier.fillMaxSize()) {
        val scroll = rememberScrollState(0)
        Header()
        Body(related, scroll)
        Title(snack, scroll.value)
        Image(snack.imageUrl, scrollProvider = { scroll.value })
        Up(upPress)
        CartBottomBar(modifier = Modifier.align(Alignment.BottomCenter))
    }
}

We can see Title reads the current scroll value. This means that any time the scroll changes, this composable will have to be recomposed.

Now looking at the Title composable.

 /* Copyright 2022 Google LLC.
  SPDX-License-Identifier: Apache-2.0 */

@Composable
private fun Title(snack: Snack, scroll: Int) {
    val maxOffset = with(LocalDensity.current) { MaxTitleOffset.toPx() }
    val minOffset = with(LocalDensity.current) { MinTitleOffset.toPx() }
    val offset = (maxOffset - scroll).coerceAtLeast(minOffset)

    Column(
        verticalArrangement = Arrangement.Bottom,
        modifier = Modifier
            .heightIn(min = TitleHeight)
            .statusBarsPadding()
            .graphicsLayer (translationY = offset)
            .background(color = JetsnackTheme.colors.uiBackground)
    ) { … }
}

It takes the scroll value and computes an offset with it, then that offset is used in a graphicsLayer modifier to achieve the translation on the screen.

The first step we can take is to defer the read of the scroll value into the Title composable. We can do this by converting the scroll parameter to instead take a lambda.

 /* Copyright 2022 Google LLC.
  SPDX-License-Identifier: Apache-2.0 */

private fun Title(snack: Snack, scrollProvider: ()->Int) {
    val maxOffset = with(LocalDensity.current) { MaxTitleOffset.toPx() }
    val minOffset = with(LocalDensity.current) { MinTitleOffset.toPx() }
    val offset = (maxOffset - scrollProvider()).coerceAtLeast(minOffset)

   …
}

// Call site would also be updated to Title(snack, scrollProvider = { snack.value })

This will defer the reading of state and at least limit the scope of recomposition to just the Title composable. This is good, but we can do better!

As we now know, we should prefer lambda modifiers when passing in a frequently changing state. Our scroll value definitely counts as frequently changing!

graphicsLayer has a lambda version we can switch to. If we use that instead we can defer the reading of state to the draw phase and this will mean composition can be skipped entirely. It won’t be enough to just switch to the lambda modifier though. As the scroll state is still being read outside of the lambda to calculate the offset, we will still require recomposition.

To fully defer the read to the draw phase, we need to move the read into the graphicsLayer {} modifier as well. We will move the calculation of the offset inside the lambda which looks like this.

 /* Copyright 2022 Google LLC.
  SPDX-License-Identifier: Apache-2.0 */

@Composable
private fun Title(snack: Snack, scrollProvider: ()->Int) {
    val maxOffset = with(LocalDensity.current) { MaxTitleOffset.toPx() }
    val minOffset = with(LocalDensity.current) { MinTitleOffset.toPx() }

    Column(
        verticalArrangement = Arrangement.Bottom,
        modifier = Modifier
            .heightIn(min = TitleHeight)
            .statusBarsPadding()
            .graphicsLayer {
                val offset = (maxOffset - scrollProvider()).coerceAtLeast(minOffset)
               translationY = offset
             }
            .background(color = JetsnackTheme.colors.uiBackground)
    ) { … }
}

Now that our Compose state is only being read inside the graphicsLayer modifier, we have deferred the read outside of composition and composition can be skipped.

If we re-run the app and open the Layout Inspector we can see that composition has been skipped entirely. There is no recomposing or even skipping of individual composables.

Layout inspector showing no recomposition

That’s it! The problem is now solved and we have achieved the same collapsing toolbar effect without using recomposition.

For more Jetpack Compose performance tips, see our performance documentation.

By Ben Trengrove
Source Medium

Previous Qualcomm Enables Japan With Complete Wi-Fi 6E Ecosystem As The Country Opens 6 GHz Band
Next An Introduction To OpenTelemetry And Observability