Gotchas in Jetpack Compose Recomposition

Justin Breitfeller
- East Stroudsburg, PA

Intro

Jetpack 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 Optimization

Since 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.

  1. All inputs must be stable.
  2. 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.
  • Strings
  • All Function types (lambdas)

Gotcha - Unstable Lambdas

In 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 References

By 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 Lambdas

Another 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 Functions

If 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 Lambda

As 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 Apps

When 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 name = remember { DomainFullName("John", "Doe") }
    var count by remember { mutableStateOf(1) }

    Column {
        //This shouldn't recompose when count increments since [name] isn't changing
        //but it does since DomainFullName is not @Stable.
        DomainFullNameComposable(name)
        Text("Click Count: $count")
        Button(onClick = { count++ }) {
            Text("Recompose domain module class test")
        }
    }
}

@Composable
private fun DomainFullNameComposable(domainObject: DomainFullName) {
    Log.e(*******COMPOSED, "DomainFullName recomposed")
    Text(domainObject.first + " " + domainObject.last)
}

In this example, whenever the button is clicked, the DomainClassTest composable will rerun since count is changing. This recomposition then forces the recomposition of DomainFullNameComposable despite the DomainFullName not changing. If the DomainFullName is moved into a module where Compose is being used, DomainFullNameComposable will no longer recompose unnecessarily.

Option 1 - Decouple @Composables from Domain Classes

One solution to the above problem is to only use classes that live within the same module as @Composable function arguments. Then, when necessary, map domain state into those Compose level classes. For example:

//Compose level state
data class ComposeFullName(val first: String, val last: String)

fun DomainFullName.toComposeFullName() = ComposeFullName(first = first, last = last)

@Composable
fun UiClassTest() {
    val name = remember { DomainFullName("John", "Doe") }
    val uiName = remember { name.toComposeFullName() }
    var count by remember { mutableStateOf(1) }

    Column {
        //This shouldn't recompose when count changes since [uiName] isn't changing and is @Stable
        ComposeFullNameComposable(uiName)
        Text("Click Count: $count")
        Button(onClick = { count++ }) {
            Text("Recompose UI module class test")
        }
    }
}

@Composable
private fun ComposeFullNameComposable(uiObject: ComposeFullName) {
    Log.e(*******COMPOSED, "ComposeFullName recomposed")
    Text(uiObject.first + " " + uiObject.last)
}

Option 2 - Don’t Use Class Arguments In @Composable Functions

Another solution is to simply pass only primitive types to Composables. In the original code sample the first and last fields could have been passed directly to the NameFromComposeModule function (instead of DomainFullNameComposable). For example:

@Composable
fun DomainClassTest() {
    val name = remember { DomainFullName("John", "Doe") }
    var count by remember { mutableStateOf(1) }

    Column {
        //This will no longer recompose since both [first] and [last] are @Stable
        DomainFullNameComposable(first = name.first, last = name.last)
        Text("Click Count: $count")
        Button(onClick = { count++ }) {
            Text("Recompose domain module class test")
        }
    }
}

@Composable
private fun DomainFullNameComposable(first: String, last: String) {
    Log.e(*******COMPOSED, "DomainFullName recomposed")
    Text(first + " " + last)
}

Gotcha - Not Minimizing the Effect of Rapidly Changing State

In one of Google’s examples on recomposition, they discuss when to use a derived state in the context of a LazyColumn. In an effort to clarify that example, consider this similar example:

@Composable
fun LazyListComposable() {
    val someListOfStrings = List(100) { index -> index.toString() }
    val listState = rememberLazyListState()

    LazyColumn(state = listState) {
        items(someListOfStrings) { numberString ->
            Text(numberString)
        }
    }

    val showButton = listState.firstVisibleItemIndex > 0

    AnimatedVisibility(visible = showButton) {
       ScrollToTopButton()
    }
}

Upon initial glance it may be difficult to spot why this is a problem. To start, consider the following:

  • This entire function will recompose every time the firstVisibleItemIndex changes because it is used to determine the value of showButton.
  • Each item’s Text() composable won’t recompose unless the value changes (i.e. scrolling a new item on the screen)
  • AnimatedVisibility should only recompose when showButton changes

So, what’s the problem? The performance penalty is actually realized with recomposition of LazyColumn. Each time LazyListComposable runs, LazyColumn must recompose because of the lambda which describes how to render the list’s items. Each Text() composable call may be skipped, but Compose still has to iterate over each item to trigger its rendering calls (whether or not they are then skipped). In other words, if the above LazyColumn looked like this:

    LazyColumn(state = listState) {
        items(someListOfStrings) { numberString ->
            Log.e("*******COMPOSED", "About to recompose item $it")
   	        Text(numberString)
        }
    }

There would be a log message for every item currently visible on the screen every time the user scrolls. This is a lot of extra work that doesn’t need to happen if showButton is not changing.

Benefits of Derived State

In this example, the showButton variable is derived from some other rapidly changing state (the firstVisibleItemIndex). In order to prevent needless recompositions, Compose provides a mechanism to react to state changes with a smaller scope: derivedStateOf. Consider the following change to showButton:

   val showButton by remember { 
        derivedStateOf { 
            listState.firstVisibleItemIndex > 0 
        } 
   }

By turning showButton into a state, any consumers of showButton will now only react to changes in its value. Additionally, the reaction to firstVisibleItemIndex changes is now contained within the scope of the derivedStateOf creation lambda. In other words, if a log was present in the derivedStateOf block, that log method would still be firing every time the list is scrolled. However, unless showButton changes, the recomposition would remain constrained to that derived state block. A full example of the new, more efficient code is as follows:

@Composable
fun LazyListComposable() {
    val someListOfStrings = List(100) { index -> index.toString() }
    val listState = rememberLazyListState()

    LazyColumn(state = listState) {
        items(someListOfStrings) { numberString ->
   	        Text(numberString)
        }
    }

    val showButton by remember { 
        derivedStateOf { 
            listState.firstVisibleItemIndex > 0 
        } 
    }

    AnimatedVisibility(visible = showButton) {
       ScrollToTopButton()
    }
}

Now, whenever the user scrolls, the entire LazyColumn is only recomposed when showButton changes. This is a huge savings given how often recomposition occurs when scrolling a list!

Summary

Hopefully, this post shed some light on some of the ways unexpected performance issues can occur within Jetpack Compose UI. While it may be tempting to go forth and fix these issues right away, my advice is to identify screens with large composable hierarchies that may be exhibiting performance issues. Recently, in Android Studio Dolphin, a recomposition counter has been integrated with the layout inspector. This tool will undoubtedly prove invaluable when examining an app for recomposition issues and help identify potential performance issues in an easy way.

Special Thanks

I’d like to take this opportunity to thank Joshua Soberg and Brian Stokes from the Stitch Fix team who assisted me in discovering some of these issues in Compose. Additionally, some of this work was prompted by this excellent article.

Additional Resources

Code Repository

Many of the code samples in this post exist here on GitHub.

Tweet this post! Post on LinkedIn
Multithreaded

Come Work with Us!

We’re a diverse team dedicated to building great products, and we’d love your help. Do you want to build amazing products with amazing peers? Join us!