Source: Stitch Fix Blog

Stitch Fix Blog Gotchas in Jetpack Compose Recomposition

IntroJetpack Compose is an amazing new declarative UI toolkit in Android that offers faster and simpler development of Android apps. Here at Stitch Fix, we've been using Compose for a while and have seen many of the improvements and challenges of this new addition. For example, as a team laser focused on the best customer experience, we've found a few "gotchas" that can really impact UI performance. This post will discuss a few of those "gotchas" encountered here at Stitch Fix and how to correct them.Recomposition in Jetpack Compose is the mechanism in which state changes are reflected within an app's UI. To accomplish this, Compose will rerun a composable function whenever its inputs change. The gotchas discussed here break performance optimizations built into Compose and trigger unnecessary work, which have the potential to slow down an app's UI and waste precious device resources.For efficiency, Compose will skip any child function or lambda calls that do not have any changes to their input. This optimization is quite important since animations and other UI elements can trigger recomposition every frame. The following example details when recomposition will occur:@Composable fun BookDescriptor( title: String, author: String, ) { Column { // The Text function will recompose when [title] changes, but not when [author] changes Text(title) Divider() // This Text function will recompose only when [author] changes Text(author) } } Go in-depth about the Jetpack Composition Lifecycle with this article on the Google Developers site.Skipping OptimizationSince recomposition can happen so frequently, one of the most important optimizations Compose does to maintain performance is calling skipping. As the name implies, this optimization will skip calls to composable functions whose inputs have not changed since the previous call. Compose determines if inputs have "changed" using a set of requirements which can be found here.For reference, there are two requirements that must be satisfied in order for a composition to be skipped.All inputs must be stable.Inputs must not have changed from the previous call.The article listed above describes what is necessary for a type to be @Stable. They are also copied below:A stable type must comply with the following contract:The result of equals for two instances will forever be the same for the same two instances.If a public property of the type changes, Composition will be notified.All public property types are also stable.There are some important common types that fall into this contract > that the compose compiler will treat as stable, even though they are > not explicitly marked as stable by using the @Stable annotation:All primitive value types: Boolean, Int, Long, Float, Char, etc.StringsAll Function types (lambdas)Gotcha - Unstable LambdasIn order to best demonstrate this "Gotcha", please consider the following code example:@Composable fun RecompositionTest() { val viewModel = remember { NamesViewModel() } val state by viewModel.state.collectAsState() NameColumnWithButton( names = state.names, onButtonClick = { viewModel.addName() }, onNameClick = { viewModel.handleNameClick() }, ) } @Composable fun NameColumnWithButton( names: List<String>, onButtonClick: () -> Unit, onNameClick: () -> Unit, ) { Column { names.forEach { CompositionTrackingName(name = it, onClick = onNameClick) } Button(onClick = onButtonClick) { Text("Add a Name") } } } @Composable fun CompositionTrackingName(name: String, onClick: () -> Unit) { Log.e("*******COMPOSED", name) Text(name, modifier = Modifier.clickable(onClick = onClick)) } The above composition renders a list of names and a button that a user can click to add a name to the list. Each of those names when clicked perform some operation within the view model. Finally, to aid in debugging, a Logcat message is presented whenever any name within names is composed.Originally, the expectation was that log messages would only occur for the names being added to the list (post the initial composition logs). After all, previous names are not changing and neither is the lambda. What does happen, however, is that every time a new name is added to the list, all names are being recomposed.To better understand why this is happening, let's take a quick detour into how lambdas are implemented. Whenever a lambda is written, the compiler is creating an anonymous class with that code. If the lambda requires access to external variables, the compiler will add those variables as fields that are passed into the constructor of the lambda. This is sometimes described as variable capture. For the onNameClick lambda, the compiler generates a class that looks something like this:class NameClickLambda(val viewModel: NamesViewModel) { operator fun invoke() { viewModel.handleNameClick() } } This implementation detail reveals why our function was recomposing! The public NamesViewModel property is violating the @Stable requirement that all public properties must also be @Stable. To verify that this is the cause of the recomposition problems, the @Stable annotation can be applied to the definition of NamesViewModel like so:@Stable class NamesViewModel : ViewModel() { // snipped for brevity } After this change and running the original test again, recomposition is behaving as initially expected. Nice! Only the new names in the list are triggering a log message. While this solution works, marking every ViewModel as @Stable is not technically correct as they don't fit Compose's description of @Stable data types.What is Safe with Lambdas?Unfortunately, there isn't a one-size-fits-all solution for every situation. Each unique situation may require a different solution.Option 1 - Method ReferencesBy using method references instead of a lambda, we will prevent the creation of a new class that references the view model. Method references are @Stable functional types and will remain equivalent between recompositions. It is for this reason that wherever possible method references are usually the best choice to pass to @Composable functions.@Composable fun RecompositionTest() { val viewModel = remember { NamesViewModel() } val state by viewModel.state.collectAsState() NameColumnWithButton( names = state.names, onButtonClick = viewModel::addName, // Method reference onNameClick = viewModel::handleNameClick, // Method reference ) } Option 2 - Remembered LambdasAnother option is to remember the lambda instance between recompositions. This will ensure the exact same instance of the lambda will be reused upon further compositions.@Composable fun RecompositionTest() { val viewModel = remember { NamesViewModel() } val state by viewModel.state.collectAsState() val onButtonClick = remember(viewModel) { { viewModel.addName() } } val onNameClick = remember(viewModel) { { viewModel.handleNameClick() } } NameColumnWithButton( names = state.names, onButtonClick = onButtonClick, onNameClick = onNameClick ) } Tip: When remembering a lambda, pass any captured variables as keys to remember so that the lambda will be recreated if those variables change.Option 3 - Static FunctionsIf a lambda is simply calling a top-level function, the base composition optimization rules applied to all lambdas still apply. For example, a call like below will require no changes:@Composable fun RecompositionTest() { val viewModel = remember { NamesViewModel() } val state by viewModel.state.collectAsState() NameColumnWithButton( names = state.names, onButtonClick = viewModel::addName, onNameClick = { someNonScopedFunction() } ) } fun someNonScopedFunction() { print("Do something") } Option 4 - Using a @Stable Type in a LambdaAs long as a lambda is only capturing other @Stable types it will not violate any skipping optimization requirements. Earlier, when temporarily marking NamesViewModel with @Stable, this solution was demonstrated. Here is an example where a lambda is modifying MutableState which is a @Stable type.@Composable fun RecompositionTest() { var state by remember { mutableStateOf(listOf("Aaron", "Bob", "Claire")) } NameColumnWithButton( strings = state, buttonName = "Recompose Lambda Capturing @Stable", onButtonClick = { state = state + "Daisy" }, onTextClick = { state = state + "Daisy" }, ) } Gotcha - Implicitly @Stable Data Classes & Multi-Module AppsWhen passing a class instance to a @Composable function, it must be marked as @Stable to satisfy skipping optimization requirements. In order to help facilitate this process, Compose will attempt to infer the stability of a data type. If all public properties are immutable and @Stable, the containing type will be marked as @Stable. This link discusses this process in more detail.While this inference tool is extremely helpful to the developer, it is important to understand how and when this happens. Consider the following example:data class FullName( val firstName: String, val lastName: String ) Can this type be marked as @Stable? The answer is: it depends on where it is! Compose will only infer the stability of this type at compile time. This means that the Compose compiler plugin must actually evaluate the code for the @Stable annotation to be applied to the data type.This caveat is a very important consideration when building multi-module Android apps. If a @Composable function uses an argument type from a module built without Compose, it will not have @Stable arguments and will violate the requirements for the skipping optimization. Here is a simple example to illustrate this point://Defined in a module without compose applied data class DomainFullName(val first: String, val last: String) @Composable fun DomainClassTest() { val n

Read full article »
Annual Revenue
$1.0-5.0B
Employees
1.0-5.0K
Matt Baer's photo - CEO of Stitch Fix

CEO

Matt Baer

CEO Approval Rating

82/100

Read more