New ways of optimizing stability in Jetpack Compose
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