Optimize App Performance By Mastering Stability in Jetpack Compose

Understanding stability in Jetpack Compose is crucial as it significantly affects your application’s performance across various scenarios. This article will guide you through the fundamental concept of stability and offer strategies for effective management.

Jaewoong E.
Jaewoong E.
Published March 15, 2024

Jetpack Compose, Google's cutting-edge UI toolkit, has shown immense promise since its stable 1.0 release. The adoption for production purposes has surged, with over 125,000 apps developed using Jetpack Compose now successfully launched on the Google Play Store, as reported by Google.

Although Jetpack Compose has built-in optimization features, developers should understand how Compose renders UI elements and the strategies for optimizing Jetpack Compose's performance under various scenarios. This knowledge is essential for minimizing potential impacts on your application's performance and resulting in a better user experience.

This article will guide you through managing stability and understanding the internal workings of Jetpack Compose to enhance your application's performance.

Jetpack Compose Phases

Before delving into stability, it's crucial to grasp the phases of Jetpack Compose, which outline the process of rendering a Compose UI node on the screen through several sequential steps.

Jetpack Compose executes the rendering of a frame through three distinct phases:

  • Composition: In this phase, the process begins with creating descriptions for your Composable functions, accompanied by allocating multiple in-memory slots. These slots memoize each Composable function, facilitating efficient recall and execution during runtime.

  • Layout: In this stage, the positioning of each Composable node within the Composable tree is established. The layout phase primarily consists of measuring and appropriately positioning each Composable node, guaranteeing the precise arrangement of all elements within the UI's overarching structure.

  • Drawing: In this last phase, Composable nodes are rendered onto a Canvas, usually the screen of the device. This vital step visually constructs your UI, making the designed composables available for user interaction.

The internal mechanisms are much more complex, yet fundamentally when you write Composable functions, they undergo these phases to be displayed on the screen.

Now, let's say you want to modify UI elements, like the size and color of your layout. Given that the Drawing phase has concluded Compose must revisit the phases from the beginning to apply these new values. This cycle of updating is known as Recomposition:

Recomposition occurs when your Composable functions are executed anew, starting from the Composition phase, in response to input changes. This process can be triggered by various factors, including observing in State, and it intricately involves the Compose runtime and compiler mechanisms at an internal level.

As you might anticipate, recomposing the entire UI tree and its elements demands substantial computational resources, directly impacting the app's performance. You can minimize the computational overhead by triggering recomposition only when necessary(skipping the recomposition when unnecessary), leading to improved UI performance.

Therefore, a deep understanding of the recomposition process, including the workings of the Compose runtime, identifying opportunities to skip recomposition, and recognizing the factors that trigger recomposition, is essential.

Now, let’s explore the concept of stability and how to optimize recomposition costs to enhance your application's performance.

Understanding Stability

As outlined in the previous section, several methods exist to trigger recomposition for updating already rendered UIs. The stability of the parameters in Composable functions stands out as a crucial factor initiating recomposition, deeply intertwined with the workings of the Compose runtime and compiler.

The Compose compiler categorizes the parameters of Composable functions into two distinct states: stable and unstable. This classification of parameter stability is used to determine whether a Composable function should undergo recomposition by the Compose runtime.

Stable vs. Unstable

Now, you might wonder how parameters are classified as stable or unstable. This determination is made by the Compose compiler. The compiler examines the types of parameters used in Composable functions and categorizes them as stable based on the following criteria:

  • Primitive types, including String, are inherently stable.
  • Function types, represented by lambda expressions like (Int) -> String, are considered stable.
  • Classes, particularly data classes characterized by immutable, stable public properties or those explicitly marked as stable by using the stability annotations, such as @Stable, or @Immutable, are considered stable. You'll delve into the specifics of these annotations in the upcoming sections.

For example, you can imagine a data class below:

kt
data class User(
  val id: Int,
  val name: String,
)

The User data class, comprising immutable primitive properties, is deemed stable by the Compose compiler.

Conversely, the compiler assesses the parameter types within Composable functions, identifying them as unstable according to the criteria outlined below:

  • Interfaces, including List, Map, and others, along with abstract classes like the Any type that are not predictable of implementation on compile time, are considered unstable. The rationale behind this classification will be discussed in more detail in a subsequent section.
  • Classes, especially data classes containing at least one mutable or inherently unstable public property, will be categorized as unstable.

For example, you can imagine a data class below:

kt
data class User(
  val id: Int,
  var name: String,
)

Despite the User data class being composed of primitive properties, the presence of a mutable name property leads the Compose compiler to classify it as unstable. This classification arises because stability is determined by evaluating the collective stability of all properties; hence, a single mutable property can result in the entire class being unstable.

Smart Recomposition

Having explored the principles of stability and the Compose compiler's method for discerning between stable and unstable types, you may be curious about the practical usages of these distinctions in triggering recomposition. The Compose compiler evaluates the stability of each parameter in composable functions, laying the foundation for the Compose runtime to utilize this information efficiently.

Once the class stability is determined, the Compose runtime employs this insight to initiate recomposition through an internal mechanism known as smart recomposition. Smart recomposition leverages the provided stability information to skip unnecessary recompositions selectively, thereby enhancing the overall performance of Compose.

Some principles underpinning how smart recomposition operates include:

  1. Equality Check: Whenever a new input is passed to a Composable function, it is invariably compared with its predecessor using the class's equals() method.
  2. Decision-Based on Stability:
    • If a parameter is stable and its value hasn't changed (equals() returns true), Compose skips recomposing the related UI components.
    • If a parameter is unstable or if it is stable but its value has changed (equals() returns false), the runtime initiates recomposition to invalidate and redraw the UI layouts.

In the scenario above, avoiding unnecessary recompositions can enhance your UI performance. This is because recomposing the entire UI tree requires considerable computational resources and can negatively impact performance if not handled properly.

Although Jetpack Compose inherently facilitates smart recomposition, it's important for developers to thoroughly understand how to make the classes used in Composable functions stable and reduce recomposition as much as possible.

Inferring Composable Functions

Now you understand how the Compose compiler determines class stability and how the Compose runtime leverages this information through an internal mechanism known as smart recomposition. Yet, another vital concept to understand involves discerning the inferring of a type of Composable function.

The Compose compiler is built with the Kotlin Compiler plugin, enabling it to analyze source code written by developers at compile-time. Moreover, it can tweak the original source code to better align with the unique attributes of Composable functions.

The compiler organizes Composable functions into several group classifications, such as Restartable, Moveable, and Replaceable, to optimize their execution. This post will specifically delve into the Restartable type, which is pivotal in recomposition.

Restartable

Restartable is a type for Composable functions as determined by the Compose compiler and serves as a cornerstone for the recomposition process. As previously explored, when the Compose runtime identifies changes in inputs, it restarts (or re-invokes) the function with these new inputs to reflect data changes accurately.

Without explicitly annotating your Composable functions with particular annotations provided by Compose runtime, most of them are considered restartable by default. This means that Compose runtime can trigger recomposition for those Composable functions whenever inputs or state changes at any time.

Skippable

Skippable represents another characteristic of Composable functions, which, under the right conditions set by smart recomposition discussed in the previous section, can entirely bypass the recomposition process. Therefore, we can assert that the skippable function is directly connected to the potential for skipping recomposition and improving UI performance, contingent upon the specific circumstances.

This capability is particularly crucial for enhancing the performance of the root Composable function, which is situated at the apex of a substantial hierarchy of function calls. By skipping the recomposition of these root composables, Compose effectively eliminates the need to invoke any of the subordinate functions in the hierarchy, streamlining the entire recomposition process.

It's important to remember that a Composable function can be both restartable and skippable simultaneously, as being skippable implies that it can also undergo restartable recomposition. Now, let's explore how to know whether the Composable functions you've written are classified as restartable or skippable.

Compose Compiler Metrics

The Compose Compiler plugin allows you to generate detailed reports and metrics focused on specific concepts unique to Compose. These insights are useful for delving into the intricacies of your Compose code, offering a precise understanding of its operation at a micro level.

To generate the Compose compiler metrics, simply add the compiler options to your root module’s build.gradle file, as demonstrated in the example below:

groovy
subprojects {
  tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {
    kotlinOptions.freeCompilerArgs += listOf(
      "-P",
      "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
        project.buildDir.absolutePath + "/compose_metrics"
    )
    kotlinOptions.freeCompilerArgs += listOf(
      "-P",
      "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
        project.buildDir.absolutePath + "/compose_metrics"
    )
  }
}

Once you've synced and built your project, you'll be able to access three distinct files generated in the /build/compose_metrics directory: module.json, composablex.txt, and classes.txt. Let’s delve into each of these files individually.

Top Level Metrics (modules.json)

This report provides high-level metrics specific to Compose, primarily aimed at generating numerical data points that can be tracked over time. The relationships between these metrics can offer insightful observations; for example, comparing the number of “skippableComposables” to “restartableComposables” yields a percentage that reflects the proportion of Composable functions that will be skipped recomposition.

Below is a sample report for the foundation module:

{
 "skippableComposables": 36,
 "restartableComposables": 41,
 "readonlyComposables": 6,
 "totalComposables": 60,
 "restartGroups": 41,
 "totalGroups": 82,
 "staticArguments": 25,
  "certainArguments": 138,
  "knownStableArguments": 377,
  "knownUnstableArguments": 25,
  "unknownStableArguments": 24,
  ..

Composable Signatures (composables.txt)

This report utilizes pseudo-Kotlin style function signatures, crafted for human readability. It details every composable function within the module, dissecting each parameter and providing specific insights about them.

The report identifies whether the overall composable function is classified as restartable, skippable, or read-only. Additionally, it labels each parameter as either stable or unstable while also marking each default parameter expression as static or dynamic, offering a comprehensive overview of the composable's characteristics.

Primarily, these signatures can be used to analyze whether your Composable function is skippable or not and to identify which parameter is unstable, potentially restraining your function from being skippable.

Below is a sample report for a Composable function:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Avatar(
  stable modifier: Modifier? = @static Companion
  stable imageUrl: String? = @static null
  stable initials: String? = @static null
  stable shape: Shape? = @dynamic VideoTheme.<get-shapes>($composer, 0b0110).circle
  stable textSize: StyleSize? = @static StyleSize.XL
  stable textStyle: TextStyle? = @dynamic VideoTheme.<get-typography>($composer, 0b0110).titleM
  stable contentScale: ContentScale? = @static Companion.Crop
  stable contentDescription: String? = @static null
)

Classes (classes.txt)

This report also utilizes pseudo-Kotlin style function signatures crafted for human readability. This file is designed to help you grasp how the stability inferencing algorithm has interpreted a specific class. At the top level, each class is categorized as stable, unstable, or runtime. "Runtime" indicates that the stability is contingent on other dependencies, which will be determined at runtime (such as a type parameter or a type in an external module).

The stability assessment is based on the class's fields, with each field listed under the class and labeled as stable, unstable, or runtime stable. The bottom line reveals the “expression” employed to determine this stability at runtime, providing a comprehensive overview of how each class's stability is evaluated.

stable class StreamShapes {
  stable val circle: Shape
  stable val square: Shape
  stable val button: Shape
  stable val input: Shape
  stable val dialog: Shape
  stable val sheet: Shape
  stable val indicator: Shape
  stable val container: Shape
}

You've explored the process of generating Compose compiler metrics, understood the significance of each file, and learned how to use this information to strive for writing more skippable Composable functions. If you're keen to delve deeper into this topic, you can check out Interpreting Compose Compiler Metrics for more detailed insights.

Stability Annotations

Now that you've gained insight into how the Compose compiler handles stability, and how these stability determinations directly impact recomposition and, potentially, your application's performance.

Let's explore how to convert unstable classes into stable ones using stability annotations from the compose-runtime library. Two primary annotations enable you to mark your class as stable: @Immutable and @Stable.

Immutable

The @Immutable annotation serves as a robust commitment to the Compose compiler, ensuring that all public properties and fields of the class will never be changed(immutable) after their initial creation. It represents a more stringent assurance than the val keyword offered at the language level. While val guarantees that a property cannot be reassigned through a setter, it still allows for the possibility to be created by a mutable data structure, such as a List initialized with MutableList.

To ensure your classes are effectively marked as stable with the @Immutable annotation, adhere to the following rules:

  1. Use the val keyword for all public properties to ensure they are immutable.
  2. Avoid custom setters and ensure public properties do not support mutability.
  3. Confirm that the types of all public properties are either inherently immutable/stable or explicitly marked with a stability annotation. For example, since interfaces are considered unstable, any interface types used as properties should also be annotated for stability.
  4. For properties that are collections, opt for the immutable collections provided by kotlinx.collections.immutable to maintain stability.

The @Immutable annotation is effective for classes adhering to the above immutability rules, playing a crucial role in skipping unnecessary recompositions and eventually improving application performance.

However, it's important to apply the @Immutable annotation judiciously. Using it inappropriately can lead to unintended skipping of recompositions, which might prevent your Compose layouts from updating as expected.

Stable

The @Stable annotation represents a strong but slightly less stringent commitment to the Compose compiler compared to the @Immutable annotation. When applied to a function or a property, the @Stable annotation signifies that a type may be mutable. This might seem paradoxical at first. The term "Stable" in this context implies that the function will consistently return the same result for the same inputs, ensuring predictable behavior despite potential mutability.

Therefore, the @Stable annotation is most suitable for classes whose public properties are immutable, yet the class itself may not qualify as stable. For instance, the State interface in Jetpack Compose exposes only an immutable property named value. However, the underlying value can still be modified through the setValue function, typically by creating a MutableState.

As demonstrated with State and MutableState, an instance of State created by MutableState will consistently get the same value from the getValue function (a getter of the value property), yielding the same result for identical inputs to the setValue function. In the code snippet provided, both the Stable and MutableState interfaces are designated with the @Stable annotation, as demonstrated below:

kt
@Stable
interface State<out T> {
    val value: T
}

@Stable
interface MutableState<T> : State<T> {
    override var value: T
    operator fun component1(): T
    operator fun component2(): (T) -> Unit
}

Immutable vs Stable

The distinction between the @Immutable and @Stable annotations and deciding which one to use might initially sound confusing. But it’s pretty simple. As mentioned earlier, the @Immutable annotation signifies that all the public properties of a class are immutable, meaning its state cannot change after it's created. On the other hand, the @Stable annotation can be applied to mutable objects, requiring that they produce consistent results for the same inputs.

The @Immutable annotation is most frequently applied to domain models, particularly when using Kotlin data classes, as demonstrated in the following example:

kt
@Immutable
public data class User(
  public val id: String,
  public val nickname: String,
  public val profileImage: String,
)

Conversely, the @Stable annotation is commonly utilized for interfaces that offer multiple implementation possibilities and may possess an internal mutable state. This is meaningful example below helps you to understand this annotation:

kt
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?
  
    val hasSuccess: Boolean
        get() = exception == null
}

Applying the @Stable annotation allows you to designate the UiState class as stable. This enables optimized skipping and intelligent recomposition, enhancing the efficiency of updates.

NonRestartableComposable

The @NonRestartableComposable annotation in Jetpack Compose is a relatively advanced feature intended to optimize recomposition behavior for certain composable functions. This annotation signals to the Compose compiler that a composable function should not be automatically restarted during recomposition due to changes in its call parameters. Typically, changes in a composable function's inputs might lead the Compose runtime to restart the function, allowing it to consume its UI output to the new inputs.

However, such restarts may not always be necessary or desired, particularly when a function's internal state or side effects need to remain intact across recompositions that would otherwise prompt a restart. Applying @NonRestartableComposable instructs the runtime to update the function's parameters without restarting it, thereby maintaining its internal state and any side effects in progress.

A prime example of @NonRestartableComposable in action is within the Side-effect APIs of the Compose runtime library. The implementation of LaunchedEffect, for instance, utilizes this annotation to ensure its effects are not unnecessarily restarted, as shown in the following code:

kt
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

However, please note that you should use the @NonRestartableComposable annotation carefully and not just as a means to enhance app performance, as indiscriminate use may lead to undesirable outcomes.

Stabilize Composable Functions

You've explored writing stable classes with the goal of optimizing your application's performance. However, achieving complete stability for Composable functions extends beyond this, as some classes, like Kotlin's collections or those from third-party libraries, may not be directly within your control.

As previously discussed, the ability to skip a Composable function during smart recomposition is determined by the stability of each of its parameters. To optimize for smart recomposition, it's crucial to ensure all parameters used within a Composable function are stable.

In this section, you’ll explore four distinct strategies to make your Composable functions skippable, enhancing performance through efficient recomposition.

Immutable Collections

Initially, you might question why interfaces, particularly kotlin.collections, are considered unstable in Jetpack Compose, even when a List doesn't permit modifications to its elements.

Let’s see a great example below to understand the reason:

kt
internal var mutableUserList: MutableList<User> = mutableListOf()
public val userList: List<User> = mutableUserList

The userList field is declared as a List, which inherently does not allow modifications to its elements. However, as indicated in the first line, this List may be instantiated from a MutableList, a mutable type of list. This means that while the List interface itself restricts modifications, its underlying implementation could be mutable. The Compose compiler is unable to deduce the implementation type, leading to the requirement that such instances be treated as unstable to ensure accurate behavior.

Therefore, the official Android documentation recommends utilizing the kotlinx.collections.immutable library or Immutable Collections of Guava to ensure the stability of collection parameters in your Composable functions.

The kotlinx.collections.immutable library provides a range of collections, such as ImmutableList and ImmutableSet that mirror the behavior of the standard kotlin.collections, with the key difference being that they are immutable. These collections are read-only, prohibiting any modifications after their creation.

Now, you may be wondering about the key factors that the Compose compiler considers when determining the stability of kotlinx.collections versus kotlinx.collections.immutable. The distinction lies within the Compose compiler's understanding of immutable collections.

For a deeper insight into how the compiler differentiates these collections, refer to the KnownStableConstructs.kt file, which is part of the Compose compiler library. As you can see the code below, Compose compiler manually holds the list of package names for class that need to be considered as stable:

kt
object KnownStableConstructs {

    val stableTypes = mapOf(
        // Guava
        "com.google.common.collect.ImmutableList" to 0b1,
        "com.google.common.collect.ImmutableSet" to 0b1,
        ..
        // Kotlinx immutable
        "kotlinx.collections.immutable.ImmutableCollection" to 0b1,
        "kotlinx.collections.immutable.ImmutableList" to 0b1,
        ..
    )
}

Examine the code snippet below, part of the Compose compiler responsible for analyzing the stability of Composable function’s parameters. It's evident that the compiler doesn’t infer stability for parameter types listed in the KnownStableConstructs class:

kt
val fqName = declaration.fqNameWhenAvailable?.toString() ?: ""
val typeParameters = declaration.typeParameters
val stability: Stability
val mask: Int
if (KnownStableConstructs.stableTypes.contains(fqName)) {
    mask = KnownStableConstructs.stableTypes[fqName] ?: 0
    stability = Stability.Stable
} else
  // infer stability
}

Lambda

When it comes to the Compose compiler, the handling of Kotlin's lambda expressions takes on a unique approach. As discussed earlier, the Compose compiler modifies developer-written source code through IR (Intermediate Representation) transformations. Consequently, the compiler generates certain conventions for lambda expressions, guiding the Compose runtime on optimizing the execution of lambdas passed to Composable functions.

The Compose compiler differentiates its handling of lambda expressions based on whether or not the lambda captures values. Capturing values in the context of a closure means that the lambda expression relies on variables external to its immediate scope. If a lambda is independent of outside variables, you can say it doesn’t capture values like the example below:

kt
modifier.clickable {
    Log.d("Log", "This Lambda doesn't capture any values")
}

When a lambda parameter doesn’t capture any values, Kotlin optimizes these lambdas by treating them as singletons, minimizing unnecessary allocations. Conversely, if a lambda that depends on variables from beyond its closure is seen as capturing values as seen in the example below:

kt
var sum = 0
ints.filter { it > 0 }.forEach {
    sum += it
}

When a lambda parameter captures external values, the outcome of its execution can vary based on those captured values. To address this, the Compose compiler employs a strategy of memorization, encapsulating the lambda within a remember function call. The captured value serves as a key parameter for remember, ensuring the lambda is re-invoked appropriately in response to changes in the captured values.

Consequently, whether a lambda captures values or not, it will be deemed stable within your Composable function. Consider a scenario where your Composable function accepts a parameter of Any type. Given that Any can encompass a wide range of values, including immutable ones, it is considered as unstable by the Compose compiler:

kt
@Composable
fun MyComposable(model: Any?) {
  ..
}

// compose compiler metrics
[androidx.compose.ui.UiComposable]]") fun MyComposable(
  unstable model: Any?,
  ..

However, if you provide a value using a lambda expression, as shown in the example below, the Compose compiler will treat the lambda parameter as stable:

kt
@Composable
fun MyComposable(model: () -> Any?) {
  ..
}

// compose compiler metrics
[androidx.compose.ui.UiComposable]]") fun MyComposable(
  stable model: Function0<Any?>,
  ..
)

Wrapper Class

Another effective strategy to stabilize your Composable function involves creating a wrapper class for any unstable classes outside your control, to which you cannot directly apply stability annotations like the example below:

kt
@Immutable
data class ImmutableUserList(
   val user: List<User>
)

You can then utilize this wrapper class as the type for the parameter in your Composable function, as demonstrated in the code below:

kt
@Composable
fun UserAvatars(
    modifier: Modifier,
    userList: ImmutableUserList,
)

File Configuration

Starting from Compose compiler version 1.5.5, you have the option to list classes in a configuration file. These specified classes will then be recognized as stable by the Compose compiler. This is very useful when you need to make some classes outside your control, such as classes from third-party libraries.

To enable this feature, add the Compose compiler configurations into your app module’s build.gradle.kts file, as shown below:

kt
kotlinOptions {
  freeCompilerArgs += listOf(
      "-P",
      "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=" +
      "${project.absolutePath}/compose_compiler_config.conf"
  )
}

Next, create a file named compose_compiler_config.conf in the root directory of your app module, as shown below:

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider kotlin collections stable
kotlin.collections.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

Once you build your project and generate Compose compiler metrics, the classes specified in the configuration file will be recognized as stable and will be able to skip the smart recomposition.

According to the official Android guide, since the Compose compiler operates on each project module independently, you can supply distinct configurations for different modules as required. Alternatively, you may opt for a single configuration at the project's root level and specify that path for each module.

One crucial aspect to remember is that the configuration file does not inherently make the defined classes stable. Instead, by utilizing the configuration file, you are making a contract with the Compose compiler. Therefore, it's imperative to use this feature judiciously to avoid inadvertently skipping the smart recomposition process in specific scenarios.

Stability In Multi-Module Architecture

Modularization of your Gradle module stands as a great strategy, offering benefits such as enhanced reusability, parallel building, decentralized team focus, and much more. The Android official guides also recommend modularization as a means to enhance scalability, improve readability, and boost the overall quality of code, tailored to the scale of your project.

Modularization introduces a unique challenge in Jetpack Compose: classes from independent modules are deemed unstable, regardless of the immutability of their public properties. To counter this, importing the compose-runtime library into your data module and marking your data classes with stability annotations is advised.

Yet, there may be instances where relying on the compose runtime library isn't ideal, especially for pure Kotlin/JVM libraries focused solely on domain data. In such scenarios, two primary solutions emerge: adopting the compose-stable marker library and leveraging file configuration to ensure stability without direct dependency on the compose-runtime library.

Compose Stable Marker

The compose-stable marker library provides stability annotations such as @Immutable and @Stable, mirroring the functionality of similar annotations in the compose-runtime library. Opting for the compose-stable marker library presents two key benefits over directly using the compose-runtime library, as outlined below:

  • Lightweight: The compose-runtime library, rich with classes, functions, and extensions, can potentially increase your application's size. In contrast, the compose-stable-marker library focuses solely on stability annotations, offering a leaner alternative. This can lead to a reduction in application size and possibly shorten build times compared to using the full compose-runtime library.
  • Dependency-Free: The compose-runtime library is packed with functionalities essential for running Compose runtime features, including SideEffect, LaunchedEffect, snapshotFlow, and various other annotations linked to the Compose compiler. This setup might inadvertently allow your module access to these APIs, even if they are unnecessary for your data module. Opting for the compose-stable-marker library eliminates the risk of unintentional access to these specialized APIs by mistake, ensuring your module remains focused and streamlined.

A great use case of of the library is found in Stream's adaptable Chat and Video SDKs for Compose. The core modules of these SDKs leverage the compose-stable-marker to designate their domain classes as stable.

To learn more about the compose-stable-marker library, visit the GitHub repository.

File Configuration

As discussed in the previous section, file configuration serves as a contract with the Compose compiler, allowing it to treat specified classes as stable, irrespective of their origins or mutability. This implies that by listing classes from other modules in the file configuration, the compiler will automatically recognize them as stable.

Again, it's important to use this feature judiciously. The Compose compiler will consistently regard these classes as stable, which could lead to unintended behaviors by tweaking smart recomposition behaviors. Additionally, debugging issues related to this forced stability can be challenging within your application.

Strong Skipping Mode

Another strategy for making your Composable functions skippable involves enabling Strong Skipping Mode. Introduced in Compose Compiler version 1.5.4, this feature allows Composable functions to be skippable, even when they include unstable parameters. Additionally, it ensures that lambdas with unstable captures are memoized for optimized performance.

Currently in the experimental phase and not yet ready for production, Strong Skipping Mode is set to be enabled by default in Compose 1.7 alpha. Its effectiveness and stability will be thoroughly evaluated before progressing to the beta stage. For the time being, you can activate this experimental feature by incorporating the following Compose compiler option:

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

Strong Skipping Mode modifies the traditional stability criteria the Compose compiler employs for determining when to skip Composable functions during recomposition. Under normal circumstances, a Composable function is considered skippable if it exclusively contains stable arguments. Strong Skipping Mode alters this convention.

Once this feature is activated, all restartable Composable functions become skippable, irrespective of whether they include unstable parameters. However, non-restartable Composable functions remain unaffected and cannot be skipped.

When evaluating whether to skip a Composable function during recomposition, this mode utilizes instance equality to compare unstable parameters with their preceding values. In contrast, stable parameters are compared using object equality, defined by Object.equals().

A Composable function will be bypassed during recomposition if all its parameters align with these criteria, optimizing performance by reducing unnecessary updates.

To exclude a Composable function from the Strong Skipping Mode, making it restartable yet non-skippable, you can apply the @NonSkippableComposable annotation. This ensures the function will always be considered for recomposition, regardless of parameter stability.

kt
@NonSkippableComposable
@Composable
fun MyNonSkippableComposable {}

Meanwhile, to ensure an object is compared using object equality rather than instance equality, you will still need to mark your domain model classes with the @Stable annotation.
For a deeper understanding of this feature and the enhanced concept of Lambda Memoization, refer to the detailed guide on Strong Skipping Mode.

Conclusion

This concludes our exploration! You've covered the concept of stability, the mechanics behind stability inference and smart recomposition, effective strategies for stabilizing your classes and Composable functions, and enhancing your application's performance.

Grasping the importance of stability is crucial, as it impacts the mechanisms for rendering UI nodes on screens, ultimately influencing your application's performance.

I hope you find these insights and practices valuable and that you'll incorporate them into your projects for improved outcomes.

If you have any questions or feedback on this article, you can find the author on Twitter @github_skydoves or GitHub if you have any questions or feedback. If you’d like to stay up to date with Stream, follow us on Twitter @getstream_io for more great technical content.

As always, happy coding!

Jaewoong