New ways of optimizing stability in Jetpack Compose

Tomáš Mlynarič
Android Developers
Published in
7 min readJul 8, 2024

--

The new strong skipping mode for controlling class stability in Jetpack Compose changes how to optimize recompositions in your app. In this blog post, we’ll cover what cases it solves for you and what needs to be manually controlled. We’ll also cover common questions you’ve had, such as whether remembering lambda functions is still needed, if kotlinx immutable collections are needed, or even how to stabilize all your domain model classes. If you’re not sure what stability is, see our documentation to learn the concepts.

Stability before strong skipping mode

There are several reasons why the Compose compiler might treat a class as unstable:

  • It’s a mutable class. For example, it contains a mutable property (not backed by snapshot state).
  • It’s a class defined in a Gradle module that doesn’t use Compose (doesn’t have a dependency on the Compose compiler).
  • It’s a class that contains an unstable property (instability nesting).

Let’s consider the following class:

data class Subscription(          // class is unstable
val id: Int, // stable
val planName: String, // stable
val renewalOn: LocalDate // unstable
)

The id and planName properties are stable, because they are of a primitive type, which is immutable. However, the renewalOn property is unstable, because java.time.LocalDate comes from the Java standard library, which doesn’t have a dependency on the Compose compiler. Because of that, the whole Subscription class is unstable.

Consider the following example with a state property using the Subscription class, which is passed to a SubscriptionComposable:

// create in a state holder (for example, ViewModel)
var state by mutableStateOf(Subscription(
id = 1,
planName = "30 days",
renewalOn = LocalDate.now().plusDays(30)
))

@Composable
fun SubscriptionComposable(input: Subscription) {
// always recomposed regardless if input changed or not
}

Historically, a composable with the input parameter of this unstable class would not be determined as skippable, and it would always be recomposed regardless if the inputs changed or not.

Stability with strong skipping mode

Jetpack Compose compiler 1.5.4 and higher comes with an option to enable strong skipping mode, which always generates the skipping logic regardless of the stability of the input parameters. This mode allows composables with unstable classes to be skipped. You can read more about strong skipping mode and how to enable it in our documentation or in the blog post by Ben Trengrove.

Strong skipping mode has two ways of determining if the input parameter changed from the previous composition:

  • If the class is stable, it uses the structural equality (.equals()).
  • If the class is unstable, it uses the referential equality (===).

After you enable strong skipping mode in your project, composables that use the unstable Subscription class won’t recompose if the instance is the same as in the previous composition.

So let’s say you have the SubscriptionComposable used in a different composable Screen that takes a parameter inputText. If that inputText parameter changes and the subscription parameter doesn’t, the SubscriptionComposable doesn’t recompose and is skipped:

@Composable
fun Screen(inputText: String, subscription: Subscription) {
Text(inputText)

// It's skipped when subscription parameter didn't change
SubscriptionComposable(subscription)
}

But let’s say you have a function renewSubscription that updates the state variable with the current day to keep track of latest day when a change occurred:

fun renewSubscription() {
state = state.copy(renewalOn = LocalDate.now().plusDays(30))
}

The copy function creates a new instance of the class with the same structural properties (if it occurs during the same day), which means that the SubscriptionComposable would recompose again, because strong skipping mode compares unstable classes with referential equality (===) and copy is creating a new instance of our subscription. Even though the date is the same, because referential equality is being used, the Subscription composable is still recomposed.

Control stability with annotations

If you want to prevent the SubscriptionComposable from recomposing when the structural data doesn’t change (equals() returns the same outcome), you need to manually mark the Subscription class as stable.

In this case, it’s a simple fix by annotating the class with @Immutable, because the class represented here can’t be mutated:

+@Immutable           
-data class Subscription( // unstable
+data class Subscription( // stable
val id: Int, // stable
val planName: String, // stable
val renewalOn: LocalDate // unstable
)

In this example, when the renewSubscription is called, the SubscriptionComposable will be skipped again, because now it uses the equals() function instead of ===, which will return true compared to the previous state.

When can this occur?

A realistic example of when you’ll still need to annotate your classes as @Immutable is when you use entities coming from the peripherals of your system, such as database entities, API entities, Firestore changes, or others.

Because these entities are parsed every time from the underlying data, they create new instances every time. Therefore, without the annotation, they would recompose.

Note: Recomposing can be faster than calling equals() on every parameter. You should always measure the effect of your changes when optimizing stability.

Control stability with stability configuration file

For classes that aren’t part of your codebase, our guidance used to be that the only way to stabilize them is wrapping the class with a class that is part of your codebase and annotate that class as @Immutable instead.

Consider an example, where you’d have a composable that directly accepts the java.time.LocalDate parameter:

@Composable
fun LatestChangeOn(updated: LocalDate) {
// present the day parameter on screen
}

If you call the renewSubscription function to update the latest change, you’ll end up in a similar situation as before — the LatestChangeOn composable keeps recomposing, regardless if it’s the same day or not. However, there’s no possibility of annotating that class in this situation, because it’s part of the standard library.

To fix this, you can enable a stability configuration file, which can contain classes or patterns of classes that will be considered stable by the Compose compiler.

To enable it, add stabilityConfigurationFile to the composeCompiler configuration:

composeCompiler {
...

// Set path of the config file
stabilityConfigurationFile = rootProject.file("stability_config.conf")
}

And create the stability_config.conf file in the root folder of your project, in which you add the LocalDate class:

// add the immutable classes outside of your codebase
java.time.LocalDate

// alternatively you can stabilize all java.time classes with *
java.time.*

Stabilize your domain model classes

In addition to classes that aren’t part of your codebase, the stability configuration file can be helpful for stabilizing all your data or domain model classes (assuming they’re immutable). This way, the domain module can be a Java Gradle module and doesn’t need dependency on the Compose compiler.

// stabilize all classes in model package
com.example.app.domain.model.*

Be aware of breaking the rules

Be aware that annotating a mutable class with the @Immutable annotation, or adding the class to the stability configuration file, can be a source of bugs in your codebase, because the Compose compiler isn't capable of verifying the contract and it might show up as something isn't recomposing when you think it should.

Forget the need to remember() lambdas

One other benefit of strong skipping is that it “remembers” all lambdas used in composition, even the ones with unstable captures. Previously, lambdas that were using an unstable class, for example a ViewModel, might’ve been the cause of recomposition. One of the common workarounds was remembering the lambda functions.

So, if you have lambdas wrapped with remember in your codebase, you can safely remove the remember call, because it is done automatically by the Compose compiler:

Screen(
-removeItem = remember(viewModel){ { id -> viewModel.removeItem(id) } }
+removeItem = { id -> viewModel.removeItem(id) }
)

Are immutable collections still needed?

The kotlinx.collections.immutable collections like ImmutableList could’ve been used in the past to make a List of items stable and thus preventing a composable from recomposing. If you have them in your codebase purely for the purpose of preventing recompositions of composables with List parameters, you could consider refactoring them to a regular List and add java.util.List into the stability configuration file.

But!

If you do that, your composable might be slower than if the List parameter was unstable!

Adding List to the stability configuration file means the List parameter is compared with the equals call, which eventually leads to calling equals on every single item of that list. In the context of a lazy list, the same equals check is then called again from the perspective of the item composable, which results in calculating the equals() call twice for many of the visible items, and possibly needlessly for all the items that aren’t visible!

If the composable containing the List parameter doesn’t have many other UI components, recomposing it can be faster than calculating the equals() check.

However, there’s no one size fits all approach here, so you should verify your choice with benchmarks!

Summary

By enabling strong skipping mode in your code base, you can reduce the need to manually craft classes to be stable. Be aware that in some cases, they still need manual crafting, but this can now be simplified with the stability configuration file!

We hope all of these changes will simplify the mental load of thinking about stability in Compose.

Want more? See our codelab on practical performance problem solving in Compose.

The code snippets in this blog have the following license:
// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

--

--