Mastering Jetpack Compose Performance With Examples: Part 1

Things one must learn to write performant compose functions

This multi-part series breaks down strategies for writing efficient composables. From understanding state in Compose to techniques for tracking recompositions, this comprehensive guide arms you with the knowledge to build faster, more reliable apps

Originally posted at GetYourGuide Engineering Blog

Jetpack Compose Duty 🎯

Jetpack Compose takes Data as input and renders the UI. Whenever there is a change in data, Compose runtime keeps track and notifies the composables listening to the data change.

But what if I told you that your code is making Compose Runtime do more work than it is supposed to do? That's the goal of this series - explain how the Compose runtime works, what causes it to be inefficient, and how to fix it.

What is a State in Compose? 🔍

First, let’s understand what a “state read” means in Compose. Whenever the composable listens to a snapshot state, any change in the state will be reflected by the composable. But how does it work under the hood?

Changes in the value of a snapshot state propagate as follows:

  1. Compose goes up the UI tree to find the closest common affected scope (restart scope) and triggers re-execution of the composables.

  2. Re-execute the composables affected by the change in state value (recomposition)

Here’s a video illustration to explain

Source: Android Official Article

A Composable function can be restartable, skippable, or both. But wait, what do restartable and skippable mean exactly?

Decoding Restartable vs. Skippable Composables

Restartable

Serves as a scope to start the recompositions, also known as restart scope.

  • Almost all composables are restartable. So that's not an issue.

  • Except inline composable.

Skippable

If all parameters of the composable are stable (don't worry, we will come back to stability later in the next article), the Compose Runtime can decide to skip its recomposition following a state change. But if one or more parameters of the composable are unstable, then that composable is non-skippable. This is because Compose has no way of knowing if the parameters are the same or not. So, by default, all composables are skippable. If you think about it, it makes sense - a slight decrease in performance is acceptable, as opposed to incorrect or buggy recomposition.

⚠️Composables that don't return Unit are neither restartable or skippable. They are value producers and should force their parents to recompose upon change.

If you take a closer look at the code sample mentioned above, you'll be wondering if the Nearest restartable function is ContactRow and not Row because Row is an inline Composable function. Functions like function Row, Column, or Box are neither restartable nor skippable as they are inline functions.

Now, we've understood what the restart scope is, the meaning of recomposition, and how it happens. Let's see how we can track the recompositions

Tracking Recompositions: Tools and Techniques ⚒️

There are primarily two ways to detect how many times your views go through recomposition:

  1. Add logs to your composables. Pretty straightforward but error-prone and a lot of manual work.

  2. Use the layout Inspector

Layout Inspector: Layout inspector is a tool in Android Studio, which is quite useful for counting/tracking recomposition and skipped recompositions.

How to use a Layout Inspector?

I'm using Android Studio Iguana | 2023.2.1 Canary 2. Steps to launch the Layout Inspector:

  • Run the app

  • Open the Layout Inspector (Double shift to open Search Everywhere and search for “Layout Inspector”)

  • Play with your app to change some states and

  • Observe the recompositions

A video illustrating how to use Layout Inspector

Using Android Studio Iguana | 2023.2.1 Canary 2

In the video above, there is one Button and Text. On click of the Button, the text fades in and fades out. As I'm clicking the button, you can see the recomposition count for Text goes from 1 -> 26 and the skipped recomposition count from 1 -> 2. The first column in the Layout Inspector shows the recomposition count, and the second column shows the skipped recompositions. But let's take a step back and try to understand how Compose views are rendered and how Jetpack Compose phases knowledge can help in optimizing the process.

Introduction to Jetpack Compose Phases

Each frame that is drawn on the screen goes through different phases. Compose renders its composables through three steps:

  1. Composition: What UI to show? Builds a UI tree of compose functions.

  2. Layout: Where to place UI? This phase consists of two steps: measurement and placement. Compose measures the children if any, and place themselves accordingly in the 2D space.

  3. Drawing: How it renders. Rendering it on pixels.

It’s virtually possible that Compose might execute three phases in each frame. This is a unidirectionalflow that happens from composition to drawing to produce a frame.

Composition: Building the UI Tree

In this phase, Compose goes through composables and builds a tree-like data structure.

Source: Android Youtube

⚠️ State reads happening in this phase for the @Composable function or lambda block might potentially affect the subsequent phases. Depending on the result of the 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.

Layout: Positioning UI Elements

Take input as a UI tree and place it in the 2D space. The phase consists of two steps: measurement and placement. Steps include the following

  1. Measure Children

  2. Decide own size

  3. Place Children

Source: Android YouTube

Measure Step: Runs the lambda passed to the Layout composable, the MeasureScope.measure method of the LayoutModifier interface and so on.

Placement Step: The lambda block of Modifier.offset { … }, and so on.

⚠️ State reads affecting the Layout phase might potentially affect the drawing phase as well

Drawing: Rendering on Pixels

State read affecting the drawing code affects the drawing phase. Common examples include Canvas(), Modifier.drawBehind, and Modifier.drawWithContent.

Source: Android YouTube

What's the point?
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. Basically, meaning keeping the affected scope to the lowest scope possible. But developers must know as well how to use this to their advantage. Let's have a look at some problem sets, what's their effect, and how can we fix it?

Practical Solutions to Common Performance Issues

Problem #1 - Localizing to Layout Phase

Code Explanation

In this code, we have a Column with a Button and Text. On Click of Button, inversing the value of isShown Boolean, which then animates the offset of the Text.

Behavior

Looks all good, right? But no. Why is Text recomposing more than 25+ times when we are only changing the offset of the Text 🤔

Cause

Composable modifier's param is changing frequently, causing the Composable to recompose.

Text is being recomposed more than 25+ times, as animateIntAsState is changing value rapidly for each value. Value is being further passed to the Modifier.offset(...) and being read in the Composition phase i.e. Modifier.offset(...), which then returns a new instance of Modifier, causing the entire Text Recomposition.

Solution

Localize the phase to the lowest possible level. In this case, the Offset changes the placement of the Composable. So we can use the localization Layout phase by using the offset modifier lambda version where the lambda block we provide to the modifier is invoked during the layout phase (specifically, during the layout phase's placement step).

Result

Now, changing the Offset of Text is not causing recompositions because the Composition phase can be left out.

Problem #2 - Localizing to Drawing Phase

Code Explanation

We have this code below, a Column with Button and Text. On Click of Button, it inverts the value of the isShown, which then changes the opacity of the Text.

Behavior

Looks all good, right? But again no. Why Text is being recomposed for more than 25+ times when we are only changing the alpha of the Text from 0 -> 0.5 and vice versa 🤔

Cause

Composable modifier's param is changing frequently, causing the Composable to recompose. Text is being recomposed more than 25+ times, as animateFloatAsState is changing value rapidly for each value which is being further passed to the Modifier.alpha(...) and this value is being read in the Composition phase, i.e Modifier.alpha(...), and returning a new instance of Modifier, causing the entire Text recomposition.

Solution

You guessed it ☑️ Localizing the phase to the lowest possible level. In this case, the animationSpec only changes the value of the alpha. So we can localize it to the drawing phase by using the graphicsLayer modifier instead of alpha modifier which triggers the recompositions for the composable.

Result

Now, on change in alpha of Text is not causing the recompositions 🥳

Implementing Performance Best Practices at GetYourGuide

At GetYourGuide,adapting these changes in our code base to make our Compose code performant 🚀 That's it for now. In the next article, we will learn about what it means for stability in Compose.

You can find the samples mentioned above in this Github Repo - hello sagar/ComposePerformance. Special thanks to Philipp Nowak, Shreyas Patil, Himanshu Singh, Kasem SM, Bianca Stan, Benedict Pregler, Milan Jovic, and Volodymyr Machekhin for reviewing the article.

- Lifecycle of composables

- Jetpack Compose Phases

- Compose Performance: Hunting for unnecessary recomposition