Mark your models as stable with the Compose runtime annotation library

In this article, we’ll look at how to address this issue using the new compose-runtime-annotation library.

Jaewoong Eum
Published

In Jetpack Compose, understanding stability is pretty important because it directly affects recomposition and overall UI performance. There are several strategies for making composable functions stable, and one of the most common is marking your classes with the stability annotations @Immutable and @Stable.

However, there’s a catch: even if your model class is entirely composed of immutable properties, it will still be marked as unstable if it comes from a different package. This becomes especially problematic when building libraries, SDKs, or apps with a multi-module architecture, since all classes from external packages are treated as unstable, something you can’t control from the call site.

In this article, we’ll look at how to address this issue using the new compose-runtime-annotation library, with a real-world example of how the RevenueCat’s Android SDK solved it in practice.

The stability problem

In Jetpack Compose, recomposition is triggered when state changes or new parameters are passed into a composable. The key factor the runtime uses to decide whether two objects are equivalent is stability. Thanks to smart stability checks, even if state or parameters change, Compose can skip recomposition when it determines the objects are stable.

However, even if you intend for a class to be stable, it will ultimately be considered unstable if it contains any unstable types. For example, consider the following model class, which might typically come from a network response:

1data class User(
2  val id: String,
3  val name: String,
4  val profiles: List<String>,
5)

Since interface types are treated as unstable, the User class above ends up unstable because the profiles property is a List. In cases like this, you can explicitly mark the class with @Immutable or @Stable to inform the Compose compiler: “I know this class is immutable, so please treat it as stable.”

1@Immutable
2data class User(
3  val id: String,
4  val name: String,
5  val profiles: List<String>,
6)

On the other hand, if your class contains only stable types, like in the example below, the Compose compiler will consider it stable by default:

1data class User(
2  val id: String,
3  val name: String,
4  val address: String,
5)
6

The problem, however, is that even if a class contains only stable types, it will still be treated as unstable if it originates from another package. For example, if the User class above comes from an open-source library, an SDK, or even another module in a multi-module architecture, the compiler will still consider it unstable.

You could technically resolve this issue by adding the compose-runtime library and annotating those classes with stability annotations. However, it’s generally not a good idea to introduce a dependency on Compose in modules that are completely unrelated, such as a module containing only plain POJO models.

Even if you add compose-runtime to other modules/libraries/SDKs, those modules then gain access to Compose runtime features like SideEffect, LaunchedEffect, snapshotFlow, StateFlow<T>.collectAsState(), and many more. These APIs will start appearing in your IDE’s autocomplete, which increases the risk of accidental or improper usage in places they don’t belong.

Compose runtime annotation library

The idea, then, is: what if we could use “only” the stability annotations in our Compose-unrelated modules, such as pure JVM or core modules?

To address this, the Jetpack Compose team recently released the compose-runtime-annotation library, which contains only the stability annotations. It is designed for Kotlin Multiplatform (JVM, Android, iOS, etc.) and is available under the following package:

1compileOnly("androidx.compose.runtime:runtime-annotation:1.9.0")

And now, you’ll be able to import only the stability-related annotations, such as @Immutable, @Stable, and @StableMarker, and mark your classes as stable with those annotations.

The reason you can use compileOnly instead of the implementation method in the Gradle file is that the only requirement is to let the Compose compiler know your classes are stable. These annotations are only utilized at compile time rather than at runtime.

One great real-world example is RevenueCat’s Android SDK. It recently adopted the compose-runtime-annotation library to mark classes in its core module as stable, improving overall UI performance. You can check out the PR to see how the library was introduced and applied.

Compose stable marker library

Before the compose-runtime-annotation library was released, there was the original Compose Stable Marker library, introduced about two years earlier, which behaves very similarly.

1compileOnly("com.github.skydoves:compose-stable-marker:1.0.2")

Now that the official library is available, it’s generally best to use it, but there’s one caveat to keep in mind. The compose-runtime-annotation library requires a compileSdkVersion of at least 34. While the library itself supports older devices (the min SDK is down to API 21), building and verifying it correctly requires modern build tools (compile SDK 34 or higher).

Most projects today are already targeting compileSdkVersion 34 or higher, especially since Google Play is pushing developers to migrate to target SDK 35, but if you’re building libraries or SDKs that are still stuck below compile SDK 34, Compose Stable Marker remains a valid alternative.

Boosting the stability

If you’re building an application consisting of several modules, there are two more ways to boost your Compose UI stabilities.

1. Stability configuration file: Starting with Compose Compiler 1.5.5, you can provide a configuration file at compile time to mark specific classes as stable. This makes it possible to treat classes you don’t control, such as standard library types like LocalDateTime, as stable.
2. Strong skipping mode: When enabled, strong skipping makes all restartable composable functions skippable, even if they have unstable parameters. Non-restartable composable functions, however, remain unskippable. This is enabled by default in Kotlin 2.0.20.

By using this, you may not always need stability annotations. However, if you’re building libraries or SDKs, you can’t assume that your users will handle every stability issue correctly. It’s still better to mark your classes as stable and provide explicit stability guarantees to ensure better performance.

Conclusion

In this article, you explored why stability matters, the challenges of stability across different packages, and how to address these issues using the compose-runtime-annotation and Compose Stable Marker libraries. With Strong Skipping mode and stability configuration files, it has become easier to make classes stable, but if you’re delivering libraries or SDKs, explicitly ensuring stability remains essential for achieving better performance.

You might also like

Share this post