Jetpack Compose: Strong Skipping Mode Explained

Ben Trengrove
Android Developers
Published in
13 min readFeb 27, 2024

--

Strong skipping mode is an experimental feature in the Jetpack Compose Compiler 1.5.4+ that is currently being tested. It is part of our work to make the code you naturally write more performant. We don’t want you to have to be experts in Compose internals in order to write good Compose code! Strong skipping mode changes the rules for what composables can skip recomposition and should greatly reduce recomposition by allowing composables with unstable parameters to be skipped, and additionally, automatically remembering lambdas with unstable captures.

This change may seem small, but the behavior change is large, as is the cost of getting it wrong. We are rolling this out carefully as it is hard to reverse changes like this. Recomposing when not required has a performance cost but your app will still function correctly for your users, not recomposing when we should have may cause genuine bugs in your apps which we obviously want to avoid!

We have currently enabled the feature in our code in the Compose libraries in 1.7.0-alpha, and we are evaluating when to turn it on by default in your code, with the goal of the stable release of Compose 1.7. Please try it out and report back any issues you find at goo.gle/compose-feedback.

TL;DR

Enable strong skipping mode in the Compose compiler to get the following behavior:

  • Composables with unstable parameters can be skipped.
  • Unstable parameters are compared for equality via instance equality (===)
  • Stable parameters continue to be compared for equality with Object.equals()
  • All lambdas in composable functions are automatically remembered. This means you will no longer have to wrap lambdas in remember to ensure a composable that uses a lambda, skips.

How do I enable strong skipping?

Set the compiler flag experimentalStrongSkipping to true on a per-module basis. This can be done in Gradle with the following snippet in your root level gradle file to enable it for all modules:

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>() {
compilerOptions.freeCompilerArgs.addAll(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
)
}

In depth changes

Composable Skippability

Strong skipping mode relaxes some of the stability rules currently applied by the Compose compiler when it comes to skipping composables. By default, the Compose compiler will mark a composable function as skippable if it only has stable values provided as arguments. Strong skipping mode changes this.

With strong skipping enabled, all restartable composable functions will be skippable, regardless of if they have unstable parameters or not. Non-restartable composable functions remain unskippable.

To determine whether to skip a composable during recomposition, unstable parameters are compared with their previous values using instance equality (===). Stable parameters continue to be compared with their previous values using object equality — Object.equals().

Note: Lambda parameters are not handled any differently, but there is a bit more nuance to how it works in practice, which will be explained in the next section.

If all parameters meet these requirements, the composable is skipped during recomposition.

One such example of a composable that would have been recomposed previously but with strong skipping mode enabled will be skipped is:

@Composable
fun ArticleList(
articles: List<Article>, // List = Unstable, Article = Stable
modifier: Modifier = Modifier // Stable
) {
// …
}

@Composable
fun CollectionScreen(viewModel: CollectionViewModel = viewModel()) {
var favorite by remember { mutableStateOf(false) }
Column {
FavoriteButton(isFavorite = favorite, onToggle = { favorite = !favorite })
ArticleList(viewModel.articles)
}
}

Without strong skipping mode enabled, when the FavoriteButton was toggled, the list of articles would also be recomposed as it has an unstable parameter type (List). With strong skipping enabled, ArticleList would be skipped as the list instance (articles), has not changed.

Lambda memoization

Strong skipping mode also enables more memoization of lambdas inside composable functions. Currently by default (or in the future with strong skipping disabled), the Compose compiler will only wrap lambdas in composable functions that only capture stable values in a remember function, additionally composable lambdas are always remembered.

Note: Lambdas with no captures are also memoized, however this is done by the Kotlin compiler and not by the Compose compiler plugin by creating a static instance of the lambda.

With strong skipping enabled, lambdas with unstable captures are also memoized. This means all lambdas written in composable functions are now automatically remembered.

Effectively this is wrapping your lambda with a remember call, keyed with the captures of the lambda, automatically e.g.

@Composable
fun MyComposable(unstableObject: Unstable, stableObject: Stable) {
val lambda = {
use(unstableObject)
use(stableObject)
}
}

roughly becomes the following with strong skipping enabled:

@Composable
fun MyComposable(unstableObject: Unstable, stableObject: Stable) {
val lambda = remember(unstableObject, stableObject) {
{
use(unstableObject)
use(stableObject)
}
}
}

The keys follow the same comparison rules as composable functions, unstable keys are compared using instance equality and stable keys are compared using object equality.

Note: This is slightly different to a normal remember call where all keys are compared using object equality.

Doing this optimization greatly increases the number of composables that will skip during recomposition as without this memoization, any composable that takes a lambda parameter will most likely have a new lambda allocated during recomposition and therefore will not have equal parameters to the last composition.

Why is it experimental?

Changing which composables can be skipped is a massive shift in behavior for Compose, possibly the biggest behavior change we have ever rolled out. We want to be careful enabling this to make sure there are no unforeseen edge cases which would make upgrading to the new Compose version unduly difficult. We have currently enabled it for our code in the Compose 1.7 alphas and will decide whether to keep it enabled before 1.7 goes beta.

We will then decide whether to switch it on by default in the compiler for everyone. It should be noted that “experimental” is similar to other APIs. We don’t think the code is bug ridden, we just aren’t confident it is in its final shape and so it may change in the future.

If you do try it out and find any issues with strong skipping, please file a bug at goo.gle/compose-feedback.

Will I still have to mark types as stable?

The need to do this should drastically decrease, but it will still be required in some cases. The main case for it will be when you want your object to be compared with object equality rather than instance equality.

We have also added stability configuration files to make this case easier to manage. It allows you to mark any class as stable. You can use configuration files with or without strong skipping enabled, they are separate but complementary features designed to help with these pain points.

Configuration files areparticularly good for external classes, such as java.time.Instant that cannot be annotated with @Stable. You can also wildcard mark whole packages as stable if that helps your use case, e.g. java.time.*.

Warning, these configurations don’t make a class stable on its own. Instead, by using these configurations, you opt in to the stability contract with the compiler. Incorrectly configuring a class could cause recomposition to break.

Will my entire app break when I switch this on?

It shouldn’t! What you might find though is you have a number of test failures. This will be based on what kind of tests you have written in your code base, but if you had tests that were specifically counting the number of compositions of a composable, then that number will most likely change, and your test will fail until you update the count.

It is enabled in Compose 1.7.0-alpha, does this mean if I use 1.7 then I am using Strong Skipping?

No. Strong skipping is a compose compiler flag, so it needs to be enabled for each module it is used on. If you use Compose 1.7 but don’t enable strong skipping, our composables will skip with the strong skipping rules and yours will continue to skip how they always have. The same thing applies to your modules, you could choose to enable strong skipping one module at a time.

Are there any examples of that worked correctly before but wouldn’t with strong skipping?

Examples of composables that would have worked before but not with strong skipping enabled are generally because they were working by accidental side effect. This most likely would occur with nested mutable objects. The following code snippet would work with strong skipping disabled, but with strong skipping enabled the list composable would be skipped.

@Composable
fun MyToggle(enabled: Boolean) {}
@Composable
fun MyList(list: List<String>) {}

@Composable
fun MyScreen() {
var list by remember { mutableStateOf(mutableListOf("Foo")) }
var toggle by remember { mutableStateOf(false) }
MyToggle(toggle)
MyList(list)

Button(
onClick = {
list.add("Bar")
toggle = !toggle
}
) { Text("Toggle") }
}

This composable was only working previously because toggle was triggering the recomposition, and the change to the mutable list was just being picked up as a side effect. Zach Klippenstein has a great post on this exact issue if you are interested: Two mutables don’t make a right.

A peek behind the scenes of Compose development

You can stop reading here if you just wanted to learn about strong skipping mode, but I thought some people might like a look under the hood of what is changing and why. These are the kinds of thoughts we go through when developing new Compose features.

Strong skipping mode tackles two key pain points of Compose development:

  • Composables not being skipped when developers think they should because of unstable inputs
  • “Unstable lambdas” being diagnosed as the cause for recomposition

Skippability

When Compose was originally being developed, we actually thought people would struggle with the opposite problem we are seeing today. We thought teams would constantly be asking “why isn’t this recomposing?!”. It turns out the complete opposite is true. We took a conservative approach to recomposition, we believed in the case where we don’t know if the inputs to a composable have changed (because they are unstable and their mutations are not tracked by the compose runtime), we should never skip. Yes, this may be slightly less performant but it will mean the app shows the correct state when something else causes recomposition (as demonstrated in the example above), which is the most important factor for the end user.

Once Compose was released we saw the opposite to our initial hypothesis to be true, developers struggling to understand why a composable is recomposing. This problem is reinforced by the layout inspector showing recomposition counts, it is the easiest tool available to diagnose performance issues in your app, and don’t get me wrong, some of them are very real and need fixing, but using recomposition counts as a tool for performance optimization has a very large flaw. Recomposition is only one part of your app’s performance and recomposition count is only an indirect measure of recomposition’s contribution towards it. You can have one very expensive composable that costs massive amounts of time to recompose, and you can have composables that are extremely cheap to recompose and an additional recomposition here and there really isn’t worth making your code more complex to avoid. Here, if you were just using the layout inspector, you might spend a lot of time optimizing the cheap composable and completely missing that the composable that is only recomposing once is actually the expensive one. This is why we have always said, only worry about recomposition when you have a measured performance issue. You should be using tools such as composition tracing and macrobenchmark to measure and quantify via time or missed frame deadlines if your code changes are actually making a difference to the end performance of your app.

Nevertheless, we agree that the current situation could be improved. Developers are getting themselves into situations that perform poorly when writing idiomatic Kotlin/Compose code. Ideally, you shouldn’t have to think about any of these except in edge cases, so we set about trying to work out how to fix this.

A common feature request we see is why don’t we show stability in Android Studio? That would make debugging much easier than digging through compiler reports. We actually implemented an early prototype of this feature.

Prototype implementation of inline stability hints in Studio

We decided not to ship this feature as there are two main problems with this solution:

  • It brings massive prominence to a compiler feature that we intend to be an advanced edge case. This will have the effect of developers thinking they have to make every parameter stable.
  • It doesn’t fix the issue with “unstable lambdas” because they will still show as stable. (Covered in detail below).

We did add this information to the debugger. Android Studio Hedgehog and above will show the recomposition state of a composable when you place a breakpoint inside of it, this includes information about stability.

Recomposition state in the Android Studio Hedgehog debugger

But, stepping back, why do you even have to know about this? Can we achieve our original goal of stability only being required in edge cases? This is how we came to the initial idea of strong skipping mode, allowing composables with unstable parameters to skip as well. This shifts our default for recomposition to from a conservative, never skip when we shouldn’t approach, to a more balanced approach which we think is more inline with what you’d intuitively expect. Crucially, we are not degrading the experience of developers who have not learned about the concept of stability, their code will most likely just get faster once we enable this by default.

“Unstable lambdas”

This still left us with the lambda problem. Possibly one of the biggest misconceptions about Compose is the concept of “unstable lambdas” causing recomposition. All lambdas in Compose are stable, so the concept of an unstable lambda is a bit of a misnomer. If we are going to improve the skippability problem, we need to tackle this pain point as well. To understand this one, we have to take a step back and look under the hood of the compiler.

The Compose compiler determines if a composable can be skipped at compile time by looking at the stability of the composables parameters. There are some composables where this is not possible to know until runtime because of things like generic types, but the majority of composables can be determined skippable or not at compile time. Lets look at an example composable that includes a lambda:

@Composable
fun NumberComposable(
current: Long,
onValueChanged: (Long) -> Unit
) { }

This composable gets processed by the compiler and marked as skippable, including the lambda being marked as stable. The compose compiler report would show the following:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun NumberComposable(
stable current: Long
stable onValueChanged: Function1<Long, Unit>
)

In this example imagine the composable is then used as follows:

@Composable
fun MyScreen(viewModel: MyViewModel) {
val number by viewModel.number.collectAsState()
var text by remember { mutableStateOf("") }
NumberComposable(
current = number,
onValueChange = { viewModel.numberChanged(it) }
)
TextField(text, onValueChanged = { text = it })
}

The developer of this composable expects that when the user types in the text field, NumberComposable will skip recomposition because its inputs haven’t changed and the compiler report also confirms that it is skippable. But at runtime they see the layout inspector showing this isn’t the case. This example is stripped right back, in reality we see developers have this problem and have their whole screen recomposing and causing jank on low end devices when a user is typing, which we agree is very bad!

But what is actually happening here? Is the compiler report lying, is the layout inspector wrong? No, this behavior is showing because MyViewModel is unstable and this causes a subtle difference which I will explain below.

The catch is that lambdas are just objects under the hood. When the MyScreen composable is recomposed, the onValueChanged lambda is reallocated. When the NumberComposable is evaluated for skipping, compose runtime looks at each argument passed into the composable and compares it to its previous value. The runtime sees that current is the same value but onValueChanged has changed because it has been reallocated and lambdas just use reference equality for their equals check (the object addresses are not the same), therefore the composable is recomposed because its inputs have changed. The recomposition was caused by the lambda object changing, not the lambda being unstable.

So why don’t all composables with lambdas never skip? This pain point is a case study in why we are always hesitant to add “compiler magic”. The more the compiler does for you, the harder it is to understand why your code isn’t working the way you think it should. In this case, back when we developed skipping we realized it wouldn’t be very effective if all composables with lambdas could never skip, that would be a huge amount of composables never skipping! So, we implemented auto-remembering of lambdas, as long as they only have stable captures. If the above composable was written without the usage of the unstable viewModel in the lambda, the composable would behave as the developer expected. Something like this

@Composable
fun NumberComposable(
current = number,
onValueChange = { stableViewModel.numberChanged(it) }
) { }

The compiler would transform the code into something like this:

@Composable
fun NumberComposable(
current = number,
onValueChange = remember(stableViewModel) { { stableViewModel.numberChanged(it) } }
{ }

As the lambda would no longer be reallocated on recomposition, thanks to the remember call, the inputs to the NumberComposable would be the same and so the composable would be skipped.

Strong skipping mode expands this auto-remembering to lambdas with unstable captures as well, which means every lambda in a composable function is now memoized. This is a trade off in memory for, what we hope is, improved runtime performance. But we couldn’t be sure, another reason we decided to roll this out slowly. So far it is looking good, our code in AndroidX has not shown any performance regression with strong skipping enabled and areas that hadn’t had their stability manually tweaked showed some decent performance improvements on recomposition with one example being the time taken to recompose a RadioGroup halved.

Skipping to the end

To sum up, enable strong skipping mode to get the following behavior:

  • Composables with unstable parameters can be skipped.
  • Unstable parameters are compared for equality via instance equality (===)
  • All lambdas in composable functions are automatically remembered. This means you will no longer have to wrap lambdas in remember to ensure a composable that uses a lambda, skips.

Hopefully this post gave you some insight into how we develop a behavior change like this. We want the code you naturally write to be performant, without you having to become an expert on the internals of Compose. But, as detailed, we have to be careful with changes like this and move slowly.

You can try out strong skipping right now with the compiler flag, or wait for us to enable it by default. If you do try it out and find any issues with strong skipping, please file a bug at goo.gle/compose-feedback.

The code snippets in this blog have the following license:

// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

--

--