I was working on implementing a pretty heavy feature in which I needed to build a spreadsheet table with different types of cells (checkbox, dropdown, text field, rating, etc.) using Jetpack Compose. It was working fine until I started adding more rows/columns and functionality (e.g. copy to all rows) to the spreadsheet, and that’s when I started seeing it lagging.
Since I spent quite some time figuring out how to debug and avoid recomposition and learned about things that were not in the official Android documentation (or I couldn’t find them 😅), I thought I would write a short article on the things I tried to help others save some time.
But before we start, some basics to remember:
First, scopes are the smallest unit of composition that Compose can re-execute to update the underlying views.
When an input or state inside the scope changes, the scope gets invalidated, and Compose schedules a recomposition.
Every non-inline composable function that returns
Unit
is wrapped in a recompose scope.
For a more in-depth explanation, please read this article by Zach Klippenstein.
Tips
1. Reusable lambdas or use method reference
Move lambdas out of a recompose scope to a variable for reuse or use method reference instead, so they don’t get recreated every time a scope is called (source of the tip with a case study).
2. Wrap List
parameters
Given that I was implementing a spreadsheet-like feature with many lists and lists of lists, this one made the most difference to me.
The Compose compiler infer List
as unstable and not skippable if the value does not change. By wrapping the list in an @Immutable
annotated class, you can tell the compiler that it is stable.
// Before
data class ViewState(
val rowHeaders: List<HeaderCell>,
val cells: List<List<Cell>>
)
// After
@Immutable
data class ImmutableList<T>(val items: List<T>)
data class ViewState(
val rowHeaders: ImmutableList<HeaderCell>,
val cells: ImmutableList<List<Cell>>
)
The drawback is you will need to use this ugly wrapper in your composables.
@Composable
fun TableDate(
title: String,
rowHeaders: ImmutableList<HeaderCell>,
cells: ImmutableList<List<Cell>>
) {
...
}
3. Logging recomposition
Using the following code I got from here, I was able to identify which composable is being recomposed multiple times in this way that I could work on avoiding them.
class Ref(var value: Int)
@Composable
inline fun LogCompositions(msg: String) {
if (BuildConfig.DEBUG) {
val ref = remember { Ref(0) }
SideEffect { ref.value++ }
Timber
.tag("Compose")
.d("Compositions: $msg ${ref.value}")
}
}
4. Compose Compiler Metrics
Finally, the last thing I did was use Compose Compile Metrics to help me optimise the last few things.
I recommend reading this blog post written by Chris Banes, which describes the metrics in great detail.
Thanks for sharing, could you clarify for the second tip with an example of when a simple list does wrong? I didn't understand when to use this wrapper.