Strong skipping does not fix Kotlin collections in Jetpack Compose ⚠️
Things to watch out for when using List, Map, Set, etc
List
and other collection interfaces are marked as unstable by the Compose compiler, since it does not know what implementation you are using at runtime (Mutable or immutable).
With strong skipping mode (enabled by default in Compose now), Composables with unstable parameters can also skip. To check if an unstable param has changed since last time, the runtime compares two instances of it (current vs previous) via referential equality (===
), instead of structural equality (==
/equals
), which is used for stable parameters only. Details here.
People tend to think that strong skipping fixes the issue with Kotlin collections, so we can start using them without fear, but this is not 100% true. Sadly, there are still some caveats we must keep in mind when using them.
Referential equality returns true
if the List
reference is the same; it does not care about its content. If we keep the same reference but update the content, the Compose runtime will still assume that the list has not changed, therefore it will not trigger recomposition, and the UI will not be updated. But the content has changed!
Note that this is still valid code in Kotlin:
val myList: List = mutableListOf()
If we did this, and emitted that list as part of our UI state, we could end up mutating that list from a reference retained in our ViewModel, but the changes would not be visible for Compose, since the list reference remains the same.
We must be careful with this and always create a new List
instance when mapping to UI state. Luckily for us, creating a new collection is very frequent since operators like map
already do so. So we must make sure we use those operators, or use any of the collection builders. So far, so good! 😊
But not completely.
There is another problem to keep an eye on. The List
instance might be new every time we map, but the content might have not changed at all. That is another case we must contemplate. In this case, the Composable will still recompose even if the actual data did not change from a user perspective.
Let's say that we have an architecture that implements UDF (very common nowadays), and our source of truth is the database. Let’s say that we observe a (maybe combined) flow of data from the database, coming through one (or many) of our repositories.
If we update some of this data in the database, the corresponding flows will emit, our ViewModel will run its mapping logic, and emit new UI state. This sounds good initially, but if the UI state we end up emitting is the same than last time (because UI might not care about the specific properties updated), it should not trigger recomposition. But it will, since the mapping logic in our ViewModel still runs and creates a new collection instance.
In this case, relying on opreators like Flow<T>.distinctUntilChanged() is convenient.
Should I use Kotlin collections then?
Don’t do premature optimization. It is only an issue if it produces a significative number of unecessary recompositions, so you can see a considerable regression in your app performance, especially on low end devices. My best advice here is to have Macrobenchmarks in place that measure the FrameTiming metric for the most important screens in your app. Or for the ones that might consider the most problematic because of their very complex UI. I might write about Macrobenchmarks in the future, but I fully recommend reading the official docs. and trying to write a few.
Macrobenchmarks will run your app in an emulator (like a UI test), perform several navigation steps and user interactions (until they reach the point you want), and then report the time that frames took to render, and the amount of frame overrun (time a frame exceeds the ideal frame time), which translates into visible jank or stutter. All this will be reported with min / mean / max values, and for multiple percentiles.
It is also highly recommendable to test your app in low end devices.
Solutions
If you decide that it is a problem for your app, there are different actions you can take, all of them described in detail in the offical docs. You can wrap collections and flag the wrapper as @Immutable
, or switch to the KotlinX Immutable Collections (that are stable by default). Or you can just decide that you are fine with Kotlin collections, whitelist them using the configuration file, and maybe add some lint rules to make sure you always create a new collection before emitting UI state from your ViewModels.
I personally like the latter. It’s a more pragmatic approach with some tradeoffs, but with the correct Lint rules you might be just fine and avoid some unecessary boilerplate from your codebase.
Follow me
If you like this content, please take the time to follow me. See you around! 👋