Mastering Jetpack Compose
Performance: Part 2

Photo by Bofu Shaw on Unsplash

Mastering Jetpack Compose Performance: Part 2

Learn how to leverage stability concepts to optimize your composables for a seamless user experience.

Originally posted at GetYourGuide Engineering Blog

What does stability mean in Jetpack Compose? 🔍

This article will explore the crucial concept of Stability in Compose. We will understand the factors that cause instability and, most importantly, how to stabilize it. This knowledge will help you write more performant composables. So, let's dive in. I recommend doing so if you still need to check out Part 1. We discussed the jetpack compose phases, the concepts of restartability and skippability, and how we can benefit from them to write performant composables.

Compose Compiler Metrics Plugin 🛠️

The Compose Compiler plugin is a powerful tool that generates reports/metrics around specific compose-related concepts. This theoretical and practical information allows us to peek behind the curtain and use it to our advantage in writing performant composables. Now that we understand its potential, let’s try to generate the reports and see how they can benefit us.

Generate Reports 📊

Compose compiler reports are not enabled by default. They can be enabled via a compiler flag. Add the following script to the root build.gradle to allow reports for all modules.

Copy to clipboard

tasks.withType().configureEach {
    kotlinOptions {
      if (project.findProperty("composeCompilerReports") == "true") {
        freeCompilerArgs += listOf(
          "-P",
          "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
        )
        freeCompilerArgs += listOf(
          "-P",
          "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
        )
      }
      freeCompilerArgs += listOf(
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=$rootDir/compose_compiler_config.conf",
      )
    }
  }

Run the following command to generate the reports

Copy to clipboard

./gradlew assembleRelease -PcomposeCompilerReports=true

⚠️ Warning: Run the above command on a release build to ensure accurate results.
⚠️ Warning: You may need to run the command above with ––rerun–tasks, to ensure that the Compose Compiler runs, even when cached.

Output Files 📁

Running the command above generates four files in each module's build directory. Whatever module has the compose code is present.

Output files are:

  1. -classes.txt: Report on the stability of classes in this module.

  1. -composables.txt: Reports on whether composables are restartable and skippable in the module.

  1. -module.json: High-level aggregate statistics of all the metrics.

  1. -composables.csv: CSV version of the composables report that you can use on CI to build some scripts.

From the above output, our primary focus should be on -classes.txt and -composables.txt. If you carefully examine the output from these two files, you’ll come across terms like stable, unstable, restartable, skippable, @static, @dynamic, and runtime stability. Don't worry if it seems overwhelming at first. We'll go through these terms, ensuring you understand their true meaning. Let’s start with the -classes.txt. Bear with me; we'll move on to practical examples once you grasp these concepts.

Output of -classes.txt

It generates reports about the class type. Outputs about the class Runtime Stability, Stable, or Unstable and tells you stability about its param types.

Important Terms 🔑

Runtime type

Stability depends on other dependencies, which will be resolved at runtime.

Stable types

There are two types:

  1. Immutable Object:
    Types created once and will not change once created, like the data class mentioned below, are made using primitives and the val keyword. They never change once created, which is why the Stable inference algorithm treats them as Stable.
Copy to clipboard

data class Contact(val name: String, val number: String)
  1. Mutable Object:
    Mutable objects are classes with mutable properties like "var," but they guarantee to inform the compose if any such property changes using Snapshot APIs, like mutableStateOf. That’s why the Stable inference algorithm also treats it as Stable.
Unstable type

This class holds a mutable data type that does not notify Compose Runtime upon a change in data. Compose Runtime is unable to validate whether these have changed or not. That’s why Stable inference algorithms treat it as Unstable. If one or more class parameters are unstable, then the stability of that class is also considered Unstable.

Terms with Associated examples ✅

Let’s revisit it with their associated examples because it's essential to understand.

What is Considered an Immutable Object?

Primitives (Int, Long, Boolean, etc.), Strings, and function types, such as lambda, are treated as Stable types.

MutableState object, i.e. mutableStateOf. They are treated as Stable types.

What is Considered Unstable?
  • Kotlin Collections, i.e., List, Set, Map, etc.some text

    • The Compose compiler cannot be sure of the class's immutability as it just sees the declared type, and the implementation could still be mutable.
  • Kotlin Flowssome text

    • They are observable, but when they emit new values, it doesn't notify the compose runtime.
  • Class from a different modulesome text

    • The data class is in a separate module. Thus, the compiler cannot infer its stability.
  • Unstable Lambdasome text

    • When Lambda is accessing the unstable type class, the passing Lambda is also unstable.
  • Class with var type variablessome text

    • var means it's mutable, and its value can be reassigned without Compose Runtime being notified.

(Diagram to help you visualize better what is considered Stable and Unstable)

We will later explore all these examples of why a particular type is considered unstable with a practical code example. This is to give a high level of what we will explore.

Before we proceed forward, you must also know about Restartable and Skippable. We’ve already covered them in Part 1. Check that out, but in short.

Restartable

This provides a scope for initiating recompositions. Most composables are restartable, except inline ones, such as Column and Row Composable.

Skippable

Composables can skip recomposition if all parameters are stable. By default, they're skippable unless the parameters are unstable.

Using the knowledge above, let's look at some practical problem sets.

Practical Solutions to Performance Issues 💡

Problem #1 - Kotlin Collections

Code Explanation

This code has a Column with Checkbox and Contacts composable, which takes a List. On Click of Checkbox, which inverses the value of isChecked Boolean, the checkbox's value is inverted.

Behavior
The code looks correct. Let's check the recomposition counts.

On Click of Checkbox, it recomposing the Contacts(...) composable as its list is not changed at all 🤔

Cause

To understand the cause, let's look at the compiler reports for the Contacts Composable.

Copy to clipboard

restartable scheme("[androidx.compose.ui.UiComposable]") fun Contacts(
    unstable contacts: List
)

So, the contacts parameter is treated as unstable. If you are wondering why, 🤔 it’s because Composable takes a List (Collection Type) as a param, an interface. The Compose compiler cannot be sure of the immutability of this class as it just sees the declared type and, as such, declares it as unstable as the implementation could still be mutable. So, Compose recomposes the Contacts composable on every recomposition because Compose is sure about whether the unstable params changed or not.

Solution #1 - Stable Annotations

To stabilize the param, i.e., the Collection type (List, Set, etc.), use a wrapper data class annotated with a stable annotation (@Immutable or @Stable) wrapping a List.

Requirements for a type to be considered stable:

  1. The result of equals() will always return the same result for the same two instances

  2. When a public property of the type changes, Composition will be notified.

  3. All public property types are stable as well.

@Immutable:

  • Properties will never change once constructed.

  • All primitive types (String, Int, Float, etc) are considered immutable and lambdas.

@Stable:

  • Properties are mutable, but compose runtime will be notified whenever anything changes.

  • Use State i.e. mutableStateOf()

Code Change

Changed the Contacts Composable param to ContactInfoOneList data class, which is a wrapper class annotated with @Immutable

The compiler reports that after the code changes 🎉, It’s stable, which means there are no more unwanted recompositions due to unstable params.

Copy to clipboard

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Contacts(
    stable contacts: ContactInfoOneList
)

Result

No more unwanted recompositions 🕺

(Figure - PS1)

Solution #2 - Kotlinx.collections.immutable

To stabilize the param, i.e., Collection type (List) using an immutable list like kotlinx.collections.ImmutableList

Note: The minimum Jetpack Compose version required is version 1.2 for the compiler to consider the kotlinx.collections.ImmutableList as stable.

Code Change

Change Contacts Composable param to ImmutableList, Which is treated as Stable.

Code from Caller, build a list using the persistentListOf function.

The compiler reports that after the code changes 🎉, It’s stable, which means there are no more unwanted recompositions due to unstable params.

Copy to clipboard

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Contacts(
    stable contacts: ImmutableList
)

Result

Refer to Figure PS1 to see the output from the layout inspector.

Solution #3 - Compose Stability Config File

With the Compose Compiler 1.5.5 release, a configuration file of classes can be defined there, which will be considered stable. The configuration file is a plain text file with one class per row. An example configuration is shown below.

(Figure - PS2)

Code Change

To use the Compose Stability Config File, we must define a compose_compiler_config.conf at the project's root, as described in Figure PS2. It can be provided to each module separately, but I added it to the subprojects. So, there is one file that we need to maintain, and all the modules can benefit from it.

That’s all we need to change on the composables side. Now, the Compose compiler will consider these classes Stable.

The compiler reports that after the code changes 🎉, It’s stable, which means there are no more unwanted recompositions due to unstable params.

Copy to clipboard

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Contacts(
    stable contacts: ContactInfoOneList
)

Result

Refer to Figure PS1 to see the output from the layout inspector.

Now, bye-bye to all the refactoring comes wrapping the data class with annotations or the Kotlinx persistent list.

⚠️Warning: These configurations don't make a class stable. Instead, you opt into a contract with the compiler by using them. Incorrectly configuring a class could cause recomposition to break.

Problem #2 - Runtime Stability

Code Explanation

In this code, we have a Column with Checkbox and Contact Composable, which takes a ContactInfo data class. On Click of Checkbox, isChecked Boolean is changed, triggering the recomposition.

Behavior

The code looks correct. Let's check the recomposition counts.

On Click of Checkbox, its recomposing is Contacts(...) composable as ContactInfo is not changed at all 🤔

Cause

Let’s take a look at the compiler reports of the Contact Composable. The ContactInfo param is unstable because its params are defined as "var," which means they're mutable and can be changed at runtime. Thus, its runtime stability is Unstable, making the composable non-skippable.

Copy to clipboard

restartable scheme("[androidx.compose.ui.UiComposable]") fun Contact(
  unstable contact: ContactInfo
)
Copy to clipboard

unstable class ContactInfo {
    stable var name: String
    stable var number: Int
     = Unstable
}

There’s also another option to see what the cause (which in this case is an unstable param) of recompositions is using the debugger; this is only available in Android Studio Hedgehog | 2023.1.1 where it shows Compose state information in the debugger and which explains about if the variable is

  • Changed: This argument now has a different value

  • Unchanged: This argument did not change

  • Uncertain: Compose is still evaluating whether the argument has changed

  • Static: Compose determined argument will never change

  • Unknown: The argument is an unstable type

This tool is a lifesaver for understanding the reason for recomposition.

Solution

Make your param as "val" instead of "var.

Code Change

Changed keyword from var to val in ContactInfo data class, which made the ContactInfo data class stable and Contact composable skippable as well.

Compiler Report of ContactInfo data class and Contact composable

Copy to clipboard

stable class ContactInfoFix {
    stable val name: String
    stable val number: Int
     = Stable
}
Copy to clipboard

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Contact(
  stable contact: ContactInfoFix
)

Result

No more unwanted recompositions 🕺

Problem #3 - Class from a separate module

Code Explanation

In this code, we have a Column with Checkbox and ContactModule composable, which takes a ContactInfo data class that is present in another module called domain; on Click of a checkbox, which inverses the value of isChecked Boolean, which then inverters the value of the checkbox and triggers the recomposition.

Behavior

Looks all good, right? But no. Why, on Click of Checkbox, its recomposing is ContactModule(...) composable as ContactInfo is not changed at all 🤔

Cause

Composable is taking a data class, which is in a separate module. Thus, the compiler cannot infer its stability. As such, it declares it unstable.

Copy to clipboard

restartable scheme("[androidx.compose.ui.UiComposable]") fun ContactModule(
    unstable contact: ContactInfo
)

Solution

  1. Create UI model in the app module + maps functions()

  2. Don't pass data class arguments if primitives are enough.

  3. Enable the Compose compiler and make the data classes stable using the Stable annotation(s). However, this will affect only the Compose runtime and not Compose-UI—Refer.

  4. Use the Compose Compiler Configuration file and define the classes inside the configuration file - Refer.

Problem #4 - Unstable Lambda

Code Explanation

This code has a column with a checkbox and a Button that can be combined. When the checkbox is clicked, the isChecked value is inverted, and recompositions trigger.

Behavior

The code looks correct. Let's check the recomposition counts.

On Click of Checkbox, it's recomposing the Button(..) Composable, but no params of the Button Composable is changed 🤔

Cause

Composable is taking a lambda as a parameter and accessing the unstable type inside that lambda.

Solution

There are two solutions:

  1. Use a Method References

  2. Remembered Lambdas.

Solution #1 - Use a Method References

Why it works? Prevent the creation of a new class, which in turn references an unstable type class. Method references are @Stable functional types.

⚠️It is not guaranteed that Unstable Lambda will be fixed if it is inside an unstable class. In such cases, the solution is to remember Lambda.

Code Change

Inside the onClick param of Button composable, instead of passing a lamba, passing the method reference.

Result

No more unwanted recompositions

Solution #2 - Remembered Lambdas.

Remember the lambda instance between recompositions. Ensure the same instance of the lambda.

💡 When remembering a lambda, pass any captured variables as keys to remember so that the lambda will be recreated if those variables change.

Code Change

Now, inside the Button Composable onClick param, pass the remembered lambda value.

Result

No more unwanted recompositions. Refer to Figure PS3.

Implementing Performance Best Practices at GetYourGuide

GetYourGuide has been adapting changes in its code base to improve the performance of its Compose code. For instance, we recently optimized our date picker so that only the changed cells are recomposed upon clicking, instead of the previous method, which recomposed almost 50 cells. We utilized the information mentioned above to make this possible.

Before Optimization

After Optimization

By the time this article was published, the Strong Skipping concept had been released, which changed this conservative approach to recomposing composables. Read it to understand how it can help you write performant composables out of the box.

The above samples are in this Github Repo - hellosagar/ComposePerformance. Special thanks to Sagar Viradya, Vaibhav Jaiswal, Piyush Pradeepkumar, Shreyas Patil, Kasem SM, Lavina Dhingana, Alireza, Milan Jovic, Benedict Pregler and Volodymyr Machekhin for reviewing the article.