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:
1234data 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 theAny
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:
1234data 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:
- 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. - 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.
- If a parameter is stable and its value hasn't changed (
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:
1234567891011121314subprojects { 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:
- Use the
val
keyword for all public properties to ensure they are immutable. - Avoid custom setters and ensure public properties do not support mutability.
- 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.
- 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:
1234567891011@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:
123456@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:
12345678@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:
12345678910@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:
12internal 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:
12345678910111213object 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:
12345678910val 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:
123modifier.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:
1234var 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:
123456789@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:
12345678910@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:
1234@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:
12345@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:
1234567kotlinOptions { 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:
123456tasks.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.
123@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