Exploring Jetpack Compose Compiler's Stability Config

Improve the performance of your app by instructing the compiler what to consider stable.

Introduction

Since its inception, Jetpack Compose has strived to be a lean and fast UI toolkit. One of the ways that Jetpack Compose achieves this is by skipping recomposition as much as possible. What this means is that, if the Jetpack Compose compiler can determine that the arguments to a composable function have not changed since its last invocation, then the composable function does not need to be called again and the result of the last invocation can be used instead.

For the compose compiler to be able to skip recompositions it needs to be able to determine whether the arguments have changed, and for this the compiler uses an equality check. However, an important caveat to keep in mind here is that there are certain classes that the compiler will not check for equality, and the prime example are the Collection classes (list, set, map, ...). When using any of these, recomposition will not be skipped and the composable function will be invoked each time. Classes that the compiler can check for equality are called stable, and those that can't be checked are called unstable.

Solving the stability issue - the legacy way

Until recently, there were a couple of ways to improve performance and avoid recomposing functions with unstable arguments. The first approach involves marking the class with the @Immutable annotation - this instructs the compose compiler that the class will not change once it's been instantiated and that it can be used to skip recomposition. This is a simple approach to improve performance, but we have to be wary that there is a contract imposed by the annotation - if we were to break the contract, by applying it to a class that is not immutable, then we would have unexpected behaviour in the app. Besides this caveat, the annotation approach also requires annotating each class that we want to be treated as stable.

The second approach involves the collection classes. Instead of using the kotlin.collection classes from the standard library we could use the Immutable collections one . These collections are treated as stable by the compiler and composable functions that accept them will be skipped. However, this has the drawback of adding a new dependency to the app, and having to convert standard collections into immutable ones before providing them to the UI, with the added cost of the copy operation.

The new approach

With the release of the Jetpack Compose compiler 1.5.4 there is now a new way to notify the compiler of what classes to treat as stable. While the feature was added in the compiler 1.5.4, a bug in the compiler prevented its use, so the first compiler where we can leverage this feature is 1.5.5. This new feature consists of a plain text file that lists the classes or packages that are to be treated as stable, and we provide this list to the compiler via a compiler argument in the build files.

Before we can see how the feature works we will need some test code so that we can verify that the stability argument does what it's supposed to. The way to inspect the stability of the classes and composable functions in our app is by enabling the compose compiler reports. This is a compiler argument that we pass to the compiler in our build.gradle.kts file, specifying what reports we want to generate and where to place them. Let's see how we enable this:

Now that we have a means to monitor the stability of our classes and functions we need some test code to exercise it. For this, we will create a simple data class that will represent some UI state, and a couple of functions that accept this state as well as some collections. Let's see our test code:

If we build the app and check the compose_reports folder, we will find 2 files named app_debug-classes.txt and app_debug-composables.txt, where app is the name of the module the report belongs to. Let's see the content of the first of those files, relating to classes:

Here we can see that the compiler has marked the State class as unstable. This is because it contains 2 unstable properties, the 2 collection properties. If any member of a class is unstable, then the whole class is unstable.

Let's see now the second file, which relates to composable functions:

Here we can make the following observations from this report:

So from this we can deduce that using unstable classes, whether they are collections or some other class that the compiler cannot infer stability from, the composable functions using them will not be skippable, and this may have a performance hit in our app.

You may have noticed the restartable attribute above - this means that the function in question can be used as an entry point for the recomposition.

Now that we have some sample code to play with and a stability baseline report, we can enable the new compiler argument and see how things differ.

Enabling the stability argument

The first thing have have to do is instruct the compiler what classes and packages we want to treat as stable. For this, we first need a plain text file listing the individual classes or packages that we want to be considered stable. In our case, we are only concerned with the collections classes, so our stability file will contain only this content

kotlin.collections.*

but in your particular case you might include anything else that you know is stable but that the compiler cannot infer. You can add a single class using its fully qualified name, a package as shown above, or a package and its subpackages, using the ** notation.

Once we have this file we have to provide it to the compiler, which we do by updating the build.gradle.kts file as shown below:

For this example I am adding the stability argument separately to emphasize what's changed, but we would typically just add all the arguments together in a single list, as we do for the report arguments.

Now that we have this, we can rebuild our app and see how the reports have changed. If we do so, we will get the results shown below. Let's start with the classes report:

We can see that the collection properties are now marked as stable and, as a result, the whole class is stable.

Let's now analyse the composables report:

We observe that now all composable functions are skippable, as all arguments are deemed stable. As a result, invocations can be skipped if the arguments have not changed.

With this simple approach we can instruct the compiler what classes we want it to treat as stable, we no longer need to annotate each class with @Immutable, copy collections into immutable collections, or pay the penalty of non-skippable composables. That said, just like annotating a class with @Immutable creates a contract with the compiler, adding a class to the stability configuration file will also establish this contract, so care must be taken that classes added to the stability config are indeed stable, as otherwise we will have unexpected behaviour.