how to ensure stability of your code

Hi, my name is Vera, I am an Android developer at Yandex Disk. We are currently actively working on migrating to Compose using a design system. There are many articles about stability in Compose, but this does not protect against errors, so I decided to share my experience in the format of an educational article.

Many who start writing in Compose do so intuitively. Why? It takes a lot of time to get used to, understand, and comprehend the approach to declarative UI, and therefore, as a rule, stability is understood much later. However, this is important, because properly organized stability reduces the number of recompositions, which improves the performance and smoothness of the application.

They say, according to the horoscope Developers are divided into four types:

If you recognize yourself in the first three types, you will find a lot of new information in this article (and will be able to quickly move on to the fourth type). If you feel like the fourth type, a lot will be familiar to you, but why not just refresh your knowledge.

Recomposition and transmissibility

Let me give you a little historical background for 30 seconds or a minute. Do you mind? Let's start with a few basic definitions and a concrete example.

Recomposition – this is redrawing the UI when the input data changes.

Smart recomposition – this is an update of only those parts of the UI for which the data has changed.

Here is our code:

Column {
  Row { Text(text) }
  Icon(icon)
}
Here is his composition tree:

Let's feed new data for the icon, but the same data for the text.

Purple blocks - recomposition was skipped, orange blocks - recomposition occurred.

Purple blocks – recomposition was skipped, orange blocks – recomposition occurred.

When we feed new data to the icon, recomposition for the text will not occur. It will be skipped.

“‎It's very easy, why am I being told all this?”‎ — you may ask. Let's consider another example. We will transfer such a model to such a composition tree.

data class Person(
    val name: String, 
    val children: List<String>
)

Let's submit data in which we will only change the value nameand use the Layout Inspector to track recompositions.

Elements where recomposition has occurred are highlighted in red. There are two columns of numbers: the first indicates the number of recompositions, and the second indicates the number of skipped recompositions.

We see that the Children node is also highlighted in red, and the number of recompositions is growing. That is, the tree looks like this:

Despite the fact that the list children has not changed, – recomposition for the branch with the list has occurred. Why? We will tell you later. This is not an obvious moment if you do not know. There are many such moments, and our task in this article is to analyze them.

So, skippability is the ability of a function to skip recomposition given the same input.. Such a function is marked as skippable by the compiler.

For optimization purposes we would like functions to be skippable, but in documentation It is said that not every function needs to achieve this.

Each skippable function generates more code than a non-skippable function. This code monitors its state and makes recomposition decisions, which means it performs more checks and is slightly less efficient. increases apk size. Don't let this put you off: the benefits of skippable functions in terms of reducing unnecessary recompositions often far outweigh this small overhead. But we really don't need to be skippable if your function:

  1. Only calls other functions.

  2. It is recomposed very rarely, almost never.

  3. Contains such complex logic that it is cheaper to do a recomposition than to check if all parameters have changed.

In all other functions we want to get skippable.

A function is marked as skippable by the compiler if all its parameters are immutable and stable.

And so we come to the end of a brief historical background and answer one of the main questions:

Let's discuss stability and how to track it

The simplest definition of stability is that the compiler itself only marks as stable those types that it can be sure you won't change without creating a new object.

However, let's take a closer look at the topic – look at the documentation, make a simplified table, and then analyze it with and without evidence:

Stable

Unstable

Primitives (Int, Float, Boolean)

String

Immutable collections
(kotlinx.collections.immutable)

Collection (List, Set, Map)

Classes with immutable and stable parameters

Classes with volatile and var parameters

Classes and interfaces marked with annotations @Stable And @Immutable

Classes from modules without compose

Lambdas

Let's return to our example above: despite the fact that the list children did not change, the recomposition occurred because List is an unstable parameter.

Extract from documentation:

However, standard collection classes such as Set, List, and Map are ultimately interfaces. As such, the underlying implementation may still be mutable.

Do we understand the reasons? Yes. Do we condemn? We never needed a reason.

What is proposed for the stability of the list? Two things:

  1. Kotlin Immutable Collections – however, in this case you will need to pull in an additional library.

  2. Wrapping in a class with the Immutable annotation.

@Immutable
data class Wrapper(
  val children: List<String>
)
  1. SnapshotStateList is a special type of list that tracks changes and automatically triggers component recomposition.

But I prefer the lazy approach – we'll talk about it in the “Lifehacks” section.

As you can see, you have to do a trick to optimize the recomposition. The moment is not obvious, and this leads to our main question: “How many more such non-obvious moments are there, and how can I check that I did everything correctly?”

Layout Inspector

We've already touched on using the Layout Inspector. It allows you to track recompositions and skip them.

To make it work for your module:
buildFeatures {
   compose true
}

And make sure you don't exclude the meta for 'META INF/androidx.compose.*.version'

If you still exclude meta, you can enable it like this:

debug {
  packagingOptions {
    pickFirst 'META-INF/androidx.compose.*.version'
  }
}

In my opinion, Layout Inspector is better suited for profiling problems – to sit, think, look for what is wrong. But there is a faster way to make sure the screen is stable.

Compose compiler reports

Connection It will take a couple of minutes, you just need to specify the path where to generate the report.

First of all, we run the command ./gradlew assembleRelease (for consistent results, you need to run it on a release build).

We generate four files. You can read more about all the files here hereI will tell you in detail only about two. In the file module-classes.txt there is information about classes in the module. Look at what information is about Person:

unstable class Person {
  stable val name: String
  unstable val children: List<String>
  <runtime stability> = Unstable
}

Class is marked as unstableThe reason is that it has an unstable parameter.

In the file module-composables.txt here is information about our composable functions:

restartable scheme("[androidx.compose.ui.UiComposable]") fun Content(
  unstable person: Person
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun Children(
  unstable children: List<String>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Name(
  stable name: String
)

The Name function is labeled as skippable — it takes in a String, and it is stable. However, Content and Children do not have such marking. Let's follow one of the tips and replace List on ImmutableList:

data class Person(
  val name: String,
  val children: ImmutableList<String>
)
stable class Person {
  stable val name: String
  stable val children: ImmutableList<String>
}

Voila, our class is now stable and our functions are marked as skippable. Bravo! You have brought stability back to this city. But you can easily take it away by adding var instead of val.

unstable class Person {
  stable var name: String
  stable val children: ImmutableList<String>
  <runtime stability> = Unstable
}

Parameter name — it is a String, which means it is stable. However, var deprives the class of this stability.

It's already happened, but once again: the compiler itself marks as stable only those classes for which it can be guaranteed that you will not change them without creating a new object.

However, you can tell him: “You are not sure, but I am sure, and I tell you to work with this class as a stable one, adding an annotation stable or immutable” You can read more about which annotation to use where here.

Let's add an annotation:

@Immutable
data class Person(
    var name: String,
    val children: ImmutableList<String>
)
stable class Person {
  stable var name: String
  stable val children: ImmutableList<String>
}

With just such a simple movement of the hand, generating a report in a couple of seconds, you can check whether you have made any obvious mistakes.

In the compiler sources you can see the types that are marked as stable by default:

val stableTypes = mapOf(
    Pair::class.qualifiedName!! to 0b11,
    Triple::class.qualifiedName!! to 0b111,
    Comparator::class.qualifiedName!! to 0,
    Result::class.qualifiedName!! to 0b1,
    ClosedRange::class.qualifiedName!! to 0b1,
    ClosedFloatingPointRange::class.qualifiedName!! to 0b1,
    // Guava
    "com.google.common.collect.ImmutableList" to 0b1,
    "com.google.common.collect.ImmutableEnumMap" to 0b11,
    "com.google.common.collect.ImmutableMap" to 0b11,
    "com.google.common.collect.ImmutableEnumSet" to 0b1,
    "com.google.common.collect.ImmutableSet" to 0b1,
    // Kotlinx immutable
    "kotlinx.collections.immutable.ImmutableCollection" to 0b1,
    "kotlinx.collections.immutable.ImmutableList" to 0b1,
    "kotlinx.collections.immutable.ImmutableSet" to 0b1,
    "kotlinx.collections.immutable.ImmutableMap" to 0b11,
    "kotlinx.collections.immutable.PersistentCollection" to 0b1,
    "kotlinx.collections.immutable.PersistentList" to 0b1,
    "kotlinx.collections.immutable.PersistentSet" to 0b1,
    "kotlinx.collections.immutable.PersistentMap" to 0b11,
    // Dagger
    "dagger.Lazy" to 0b1,
    // Coroutines
    EmptyCoroutineContext::class.qualifiedName!! to 0,
)

Among them there is just Kotlin Immutable Collection.

Debug

With the Layout Inspector you can see which function is being recomposed. With the debugger you can see what is causing it. If you know which function is being recomposed you can set a breakpoint in it and look at the Recomposition State field.

It will contain all the parameters that the function accepts:

  • Changed: the argument has changed and caused a recomposition.

  • Unchanged: the argument has not changed.

  • Uncertain: Compose also evaluates whether the argument or its stability has changed.

  • Static: The argument always remains unchanged, so it can be omitted when checking for changes.

  • Unknown: The argument has an unstable type and caused a recomposition.

Composition Tracing

When recomposition is called too often, debugging may not be a very convenient way. If you see that your UI is slow, and the previous methods did not help to localize the problem, you will need method tracing. This isn't really related to stability, so if you're having performance issues, I recommend you take a look. this report.

Non-obvious moments

We've covered the basics of stability and profiling methods, and now a section on non-obvious points.

Interfaces

The documentation says that interfaces are generally considered unstable.

Let's check it out. Let's create an interface and implement it in two ways (yes, there's a small spoiler in the class names).

class Unstable(var unstableParameter: String) : SomeInterface {
    override fun someFunction() = Unit
}

class Stable : SomeInterface {
    override fun someFunction() = Unit
}

And let's write such a function:

@Composable
fun Content(
    stable: SomeInterface,
    unstable: SomeInterface,
    count: Int,
    increaseCounter: () -> Unit
) {
    Column {
        StableInterfaceConsumer(stable)
        UnstableInterfaceConsumer(unstable)
        Button(onClick = { increaseCounter() }) {
            Text(text = "Count $count")
        }
    }
}

We launch our report and see something inconsistent:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Content(
  stable: SomeInterface
  unstable: SomeInterface
  stable count: Int
  stable increaseCounter: Function0<Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun StableInterfaceConsumer(
  some: SomeInterface
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun UnstableInterfaceConsumer(
  some: SomeInterface
)
unstable class Unstable {
  stable var unstableParameter: String
  <runtime stability> = Unstable
}
stable class Stable {
  <runtime stability> = Stable
}

Everything seems clear, but not completely. Let's ask Layout Inspector to resolve our conflict. Click the button and trigger recomposition:

The Composable function missed recomposition for an unstable implementation. We look into the compiler sources and see this line:

if (declaration.isInterface) {
  return Stability.Unknown(declaration)
}

Judging by the source code, one can conclude that despite the fact that Stability.Unknown And Stability.Runtime These are different markers, they behave the same way.

If isUncertain returns truethe type has a computed stability. From this we conclude that the interface is not guaranteed to be stable or unstable.

Models in other modules

Let's create two modules – one with Compose connected, the other without. We'll put two models in each of them.

data class DataInComposeModule(val name: String)

data class DataInNonComposeModule(val name: String)

Is there anything more stable? Let's check.

restartable scheme("[androidx.compose.ui.UiComposable]") fun Content(
  dataInComposeModule: DataInComposeModule
  unstable dataInNonComposeModule: DataInNonComposeModule
  stable count: Int
  stable increaseCounter: Function0<Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ComposeModuleConsumer(
  dataInComposeModule: DataInComposeModule
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun NonComposeModuleConsumer(
  unstable dataInNonComposeModule: DataInNonComposeModule
)

We look at the report and see that even the most stable model in the noncompose module will be considered unstable.

What about the model that is in the compose module? Why isn't stability written for it? The compiler will recognize stability at compile time, and in such cases it will be calculated at runtime.

// supporting incremental compilation, where declaration stubs can be
// in the same module, so we need to use already inferred values
stability = if (isKnownStable && declaration.isInCurrentModule()) {
  Stability.Stable
} else {
  Stability.Runtime(declaration)
}

Using the Layout Inspector, we'll make sure that recomposition will be skipped for it.

Lambdas

Let's look at this example:

ComposeTheme {
  var counter by remember { mutableIntStateOf(0) }
  val viewModel = remember { ExampleViewModel() }
  ContentLambda(
    count = counter,
    lambdaUseStable = { counter++ } ,
    lambdaUseUnstable = { viewModel.soSmth() }
  )
}

In the report our lambdas are marked as stable:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ContentLambda(
  stable count: Int
  stable lambdaUseStable: Function0<Unit>
  stable lambdaUseUnstable: Function0<Unit>
  stable <this>: LambdaExmapleActivity
)

But there is a nuance:

So why does recomposition happen? Let's debug, and in the Recomposition State field we'll see:

Besides count changed and lambdaUseUnstablebut why, if we didn’t change anything?

If we decompile our screen (Tools → Kotlin → Show Kotlin Bytecode → Decompile or Decomposer), then we will see the following:

int var10001 = invoke$lambda$1(counter$delegate);
$composer.startReplaceableGroup(-180925510);
invalid$iv = $composer.changed(counter$delegate);  // проверяет если изменился ли counter
$i$f$cache = false;
it$iv = $composer.rememberedValue(); // получаем закэшированное значение
var10 = false;
Object var10002;
if (!invalid$iv && it$iv != Composer.Companion.getEmpty()) {  // если не изменился и проинициализирован не создаем новый экземпляр, а используем тот что мы взяли из кэша
  var10002 = it$iv;
} else {
  int var15 = var10001;
  LambdaExmapleActivity var14 = var25;
  var11 = false;
  Function0 var16 = <undefinedtype>::invoke$lambda$5$lambda$4;
  var25 = var14;
  var10001 = var15;
  Object value$iv = var16;
  $composer.updateRememberedValue(value$iv);  // кэшируем экземпляр лямбды
  var10002 = value$iv;
}

Function0 var18 = (Function0)var10002;
$composer.endReplaceableGroup();
var25.ContentLambda(var10001, var18, <undefinedtype>::invoke$lambda$6, $composer, 0);

Nothing is clear, let's try to simplify. It turns out something like:

ComposeTheme {
  val counter = remember { mutableIntStateOf(0) }
  val viewModel = remember { ExampleViewModel() }
  ContentLambda(
    count = counter.intValue,
    lambdaUseStable = remember(counter) { { counter.intValue++ } } ,
    lambdaUseUnstable = { viewModel.soSmth() }
  )
}

The compiler wraps lambdas that use a stable type in remember.

That is, the same copy always came to the input for lambdaUseStable. And the lambda using the unstable parameter did not turn around. And each recomposition here created a new instance.

Stable types are compared via equals → a new lambda instance was supplied as input → equals returned false → recomposition occurred.

If you want to avoid this, you need to wrap such lambdas in remember yourself. Or free your beautiful head from worries and look into the “Lifehacks” section.

Lifehacks

Strong skipping mode

So small, but it makes life so much easier flagLet's turn it on and see what it can do.

composeCompiler {
   enableStrongSkippingMode = true
}

First of all, a lambda with an unstable parameter now also turns into remember. And you and I will never know the reasons that led to not making this the default behavior.

And the best part. It will reduce your pain from unstable parameters. Functions with unstable parameters become skippable. However, unlike stable parameters, which are compared via ==unstable ones are compared through ===.

This does not mean that we should forget about stability, because we update the state quite often through copywhich means that recomposition for unstable types will continue to occur.

Kotlin 2.0.20 was released recently, and this flag is enabled by default. It seems that over time we will come to the point where this behavior will indeed become the norm, and about a quarter of what you read today (lambdas at least) can be forgotten.

Stability configuration file

Now you can create fileand list everything you want to be considered stable. And no more dancing to make your List considered stable.

Conclusions

  1. Not everything that seems stable is stable.

  2. Instability won't necessarily cause performance issues on a simple screen, but if you have a large list, scrolling, animations, the likelihood increases.

  3. Enable Compose Compiler Report for stability analysis. It's fast and easy to prevent future errors.

  4. To track recompositions and skip them – Layout Inspector.

  5. To find out what caused the recomposition, use debug and look in the Recomposition State field – the parameter that caused the recomposition will be marked as Unstable or Changed.

  6. Strong skipping mode and stability config are your friends, but trust in your friend, and don’t be lazy yourself and continue writing code taking stability into account.

Useful materials

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *