Overcoming Common Performance Pitfalls in Jetpack Compose
Jetpack Compose has a dedicated optimization strategy, but we must remember several things while writing code. These points are like rules that we need to follow daily to improve performance and save our application from severe performance pitfalls.
A Quick Recap to the Rendering
Jetpack Compose renders a frame in the following 3 phases: Composition, Layout, and Drawing. On a high level, the responsibilities of these phases are: “What to show”, “Where to show” and “How to show” respectively. In the Composition phase, the runtime executes the composable functions and generates a UI tree consisting of layout nodes, that contain all information for the next 2 phases. In the Layout phase, with the help of the UI tree information, each node’s size and location are determined in 2D space. The entire UI tree is traversed and each node does these: Measure its children if any, based on the measurements decide on its own size, and each child node is placed in 2D space relative to the nodes’s own position. In the Draw phase, each node is traversed again from the top of the tree to the bottom, and based on the size and position measurements from the previous phase, they are drawn on the screen.
These are repeated in every frame where data is changed. Also, you can skip some phase(s) if data is not changed. Compose tracks what state is read within each of them. This allows Compose to notify only the specific phases that need to perform work for each affected element of your UI. This is extremely important for us to understand because based on the code we are writing we can skip some phases in the rendering, which can improve performance.
A Quick Recap to Stability in Compose
Compose considers types to be either stable
or unstable
. A type is stable if it is immutable, or if Compose can know whether its value has changed between recompositions (notify Composition upon mutating). A type is unstable if Compose can’t know whether its value has changed between recompositions. If a composable has stable parameters that have not changed, Compose skips it. If a composable has unstable parameters, Compose always recomposes it when it recomposes the component’s parent. We can use the Compose compiler reports, using which the Compose compiler can output the results of its stability inference for inspection. The compiler marks functions to be either skippable
or restartable
. A skippable function denotes Compose can skip it during recomposition if all its arguments are equal with their previous values. Normally a function is marked as skippable if all its parameters are stable
and thus Compose can infer when it has or has not changed. A restartable function denotes that this composable can serve as a restarting “scope”. This means that whenever this Composable needs to recompose, it will not trigger the recomposition of its parent scope. Rather this function itself can be a point of entry for where Compose can start re-executing code for recomposition after state changes.
Rules to Overcome Common Pitfalls
1. Avoid using unstable collections as much as possible
The Compose compiler cannot be completely sure that collections such as List
, Map
, and Set
are truly immutable and therefore mark them as unstable. This means, that if we use unstable collections as an argument in a composable function, the argument will be marked as unstable. This even the argument does not change, the composable will recompose if the parent recomposes.
Consider this example to understand: Here when the FavoriteButton
was toggled, the list articles
would also be recomposed as it has an unstable parameter type: List
. Even if the Article
class is stable in this example, that won’t help because the List is unstable.
@Composable
fun UnstableListScreen(viewModel: ListViewModel = viewModel()) {
var favorite by remember { mutableStateOf(false) }
Column(
modifier = Modifier.padding(16.dp)
) {
FavoriteButton(isFavorite = favorite, onToggle = { favorite = !favorite })
Spacer(modifier = Modifier.height(16.dp))
UnstableList(viewModel.articles)
}
}
@Composable
private fun UnstableList(
articles: List<Article>, // List = Unstable, Article = Stable
modifier: Modifier = Modifier // Stable
) {
LazyColumn(modifier = modifier) {
items(articles) { article ->
Text(text = article.name)
}
}
}
Overcome:
Solution 1: To overcome this issue, we can use the Kotlinx Immutable collections instead of using the default collections. Here the same code is modified by using the PersistentList
from the Immutable Collections library as a replacement of List
. This makes articles
as stable. Hence, when the FavoriteButton
was toggled, the list of articles was not recomposed.
@Composable
fun StableListScreen(viewModel: ListViewModel = viewModel()) {
var favorite by remember { mutableStateOf(false) }
Column(
modifier = Modifier.padding(16.dp)
) {
FavoriteButton(isFavorite = favorite, onToggle = { favorite = !favorite })
Spacer(modifier = Modifier.height(16.dp))
StableList(viewModel.articles)
}
}
@Composable
private fun StableList(
articles: PersistentList<Article>, // PersistentList = Stable, Article = Stable
modifier: Modifier = Modifier // Stable
) {
LazyColumn(modifier = modifier) {
items(articles) { article ->
Text(text = article.name)
}
}
}
Solution 2: Though Solution 1 is a much more straightforward approach, one thing to consider here is the library is still in Alpha. So if we are skeptical about using this library in production code, we can use another approach: Using an Immutable wrapper around a standard List.
We should also follow this approach if the type of class used in the List cannot be made stable. Thus even if the List
or Article
is unstable, ArticleList
will be stable because we are using the @Immutable
annotation.
@Immutable
data class ArticleList(
val articles: List<Article> // List = Unstable or Article = Unstable
)
Then instead of using the List
directly in the above code, we can use this ArticleList
.
@Composable
private fun StableList(
articles: ArticleList, // ArticleList = Stable
modifier: Modifier = Modifier // Stable
) {
LazyColumn(modifier = modifier) {
items(articles) { article ->
Text(text = article.name)
}
}
}
2. Remember the code inside the clickable() Modifier
If we use the clickable()
modifier on a composable, the lambda onClick
of each item is reallocated every time the parent recomposes. This is because the object of the lambda is not auto-remembered.
Consider this example to understand: Here on adding each item to the LazyColumn
(on the press of the Button
), the entire list including the previous items is recomposed. This is because we have the clickable()
modifier on each item of the list. So the lambda onClick
of each item is reallocated every time the LazyColumn
recomposes. This is because the object of the lambda is not remembered. This might cause serious performance issues as the remaining items on the list have no reason to recompose unnecessarily.
@Composable
fun ListWithNonRememberedClickableItems(viewModel: ListViewModel = viewModel()) {
val dynamicList by viewModel.dynamicArticles.collectAsState()
Column(
modifier = Modifier.padding(16.dp)
) {
Button(
onClick = { viewModel.addArticleToDynamicList() }
) {
Text(text = "Add item")
}
Spacer(modifier = Modifier.height(32.dp))
ListWithNonRememberedClickableItems(dynamicList)
}
}
@Composable
private fun ListWithNonRememberedClickableItems(
articles: List<Article>,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
LazyColumn(modifier = modifier) {
items(articles) { article ->
Text(
modifier = Modifier.clickable { // Not remembered
Toast.makeText(context, "Clicked item: ${article.id}", Toast.LENGTH_SHORT).show()
},
text = article.name
)
}
}
}
Overcome: To overcome this issue, we can wrap the clickable()
Modifier inside a remember
block. Thus the lambda object will be remembered and the won’t be reallocated every time the parent recomposes.
@Composable
fun ListWithRememberedClickableItems(
articles: List<Article>,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
LazyColumn(modifier = modifier) {
items(articles) { article ->
Text(
modifier = Modifier.then(
remember {
Modifier.clickable { // Remembered
Toast.makeText(context, "Clicked item: ${article.id}", Toast.LENGTH_SHORT).show()
}
}
),
text = article.name
)
}
}
}
Consider another simple example: Here typing anything in the TextField
will recompose the parent composable function. But the Text
composable “Toggle me” has nothing to do with it. Still, it will recompose, as it is using the clickable()
Modifier, which is not remembered.
@Composable
fun ChildWithNonRememberedClickableModifier() {
var text by remember { mutableStateOf("") }
var isClicked by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(24.dp)) {
Text(
modifier = Modifier,
text = "Toggle state: $isClicked"
)
Spacer(modifier = Modifier.height(8.dp))
Text(
modifier = Modifier
.padding(8.dp)
.clickable { isClicked = !isClicked },
text = "Toggle me"
)
Spacer(modifier = Modifier.height(16.dp))
TextField(value = text, onValueChange = { text = it })
}
}
Overcome: Similarly, to fix this issue, we can wrap the clickable()
Modifier inside a remember
block. Thus the lambda object will be remembered and the won’t be reallocated every time the parent recomposes.
@Composable
fun ChildWithRememberedClickableModifier() {
var text by remember { mutableStateOf("") }
var isClicked by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(24.dp)) {
Text(
modifier = Modifier,
text = "Toggle state: $isClicked"
)
Spacer(modifier = Modifier.height(8.dp))
Text(
modifier = Modifier
.padding(8.dp)
.then(
remember {
Modifier.clickable { isClicked = !isClicked }
}
),
text = "Toggle me"
)
Spacer(modifier = Modifier.height(16.dp))
TextField(value = text, onValueChange = { text = it })
}
}
3. Remember lamdas with calls on an unstable object
This is very well explained by Ben Trengrove in the “Unstable lambdas” section of the blog Strong Skipping Mode Explained. In short, as of now, the compose compiler does not auto-remember lambdas with unstable captures. And as discussed before if lambdas are not remembered, they will be reallocated every time the parent recomposes.
Consider this example to understand: Here as ListViewModel
is unstable, the lambda onValueChnage
containing the call viewModel.numberChanged(it)
is not auto-remembered by the compiler. Thus when the parent recomposes due to the change in the TextField
input, the NumberComposable
also recomposes, even when it has nothing to do with the change of TextField.
@Composable
fun CallOnUnstableObjectWithNonRememberedLambda(viewModel: ListViewModel = viewModel()) { // ListViewModel is unstable
val number by viewModel.number.collectAsState()
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
NumberComposable(
current = number,
onValueChange = { viewModel.numberChanged(it) }
)
Spacer(modifier = Modifier.height(16.dp))
TextField(value = text, onValueChange = { text = it })
}
}
Overcome: To fix this problem, the onValueChange
lambda content should be wrapped in a remember
block.
@Composable
fun CallOnUnstableObjectWithRememberedLambda(viewModel: ListViewModel = viewModel()) { // ListViewModel is unstable
val number by viewModel.number.collectAsState()
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
NumberComposable(
current = number,
onValueChange = remember { { viewModel.numberChanged(it) } }
)
Spacer(modifier = Modifier.height(16.dp))
TextField(value = text, onValueChange = { text = it })
}
}
4. Avoid using background() Modifier while animating color
Let’s think about the 3 phases of rendering we discussed at the start of this blog: Composition, Layout, and Draw. The background()
modifier takes a color argument which is quite useful at times. But it’s also worth highlighting that this modifier causes all the 3 phases of rendering to run, even though it could have skipped the Composition and Layout phase (as the content or position of the Box is not changing, just the color is changing). This means if we are changing the argument color
very frequently then heavy computation is done because the composable might recompose that frequently.
Consider this example to understand: Here the background()
modifier is used on a Box
composable, and the color
is changed very frequently by using animation. As the color changes every 1 second, the Box
recomposes every 1 second.
@Composable
fun CompositionInEveryPhase() {
Box(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
var isNeedColorChange by remember { mutableStateOf(false) }
val startColor = Color.Blue
val endColor = Color.Green
val backgroundColor by animateColorAsState(
if (isNeedColorChange) endColor else startColor,
animationSpec = tween(durationMillis = 800, delayMillis = 100, easing = LinearEasing),
label = "Animate background color"
)
LaunchedEffect(Unit) {
while (true) {
delay(1000)
isNeedColorChange = !isNeedColorChange
}
}
Box(
modifier = Modifier
.size(300.dp)
.align(Alignment.Center)
.background(color = backgroundColor)
)
}
}
Overcome: To fix this issue, we can use the drawBehind{}
lambda modifier and drawRect()
function to draw the background color, which will only run the Draw phase, just skipping the first 2 phases.
@Composable
fun CompositionOnlyInDrawPhase() {
// Remaining code
Box(
modifier = Modifier
.size(300.dp)
.align(Alignment.Center)
.drawBehind {
drawRect(color = backgroundColor)
}
)
}
}
5. Avoid using transform modifiers directly while animating value
We often need to perform transform animations in Compose (rotation, scale, position). While using direct transform modifiers like Modifier.rotate()
if we change the value of the argument: degrees
, then the entire composable will recompose. This means if we are changing the argument degrees
very frequently then heavy computation is done because the composable might recompose that frequently. This recomposition is unnecessary since the appearance of the composable is not changing, we are just rotating it.
Consider this example to understand: Here we are using the rotate modifier directly on a Box
composable, and changing the rotation degrees
using an infinite animation. As a result, the Box
recomposes whenever the value of the degree animates.
@Composable
fun TransformUsingRotateModifier() {
Box(modifier = Modifier.fillMaxSize()) {
val transition = rememberInfiniteTransition(label = "Infinite transition")
val rotationDegree by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(animation = tween(3000)),
label = "Infinite animation"
)
Box(
modifier = Modifier
.align(Alignment.Center)
.rotate(rotationDegree * 360f)
.size(100.dp)
.background(Color.Gray)
)
}
}
Overcome: To fix this performance caveat, we should use the graphicsLayer{}
lambda modifier whenever possible. This Modifier only affects the draw phase, hence skipping the Composition and Layout phases, thus skipping recompositions. We should use it whenever possible while dealing with clipping, transform, rotation, or alpha changes.
@Composable
fun TransformUsingGraphicsLayerModifier() {
// Remaining code
Box(
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer {
rotationZ = rotationRatio * 360f
}
.size(100.dp)
.background(Color.Gray)
)
}
}
That’s all for this article!
All the examples used in this blog are available in this repo:
Use the “Layout Inspector” tool of Android Studio to run the examples and see the recompositions count, which will help a lot to understand.
I hope this writing will help many developers avoid these common pitfalls and write Jetpack Compose code with best practices in mind.
Subscribe to my newsletter
Read articles from Pushpal Roy directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Pushpal Roy
Pushpal Roy
I'm a passionate coder, dedicated to bringing creative ideas to life in the digital realm. With over 8 years of hands-on development experience, I specialize in the dynamic realms of Android and Flutter, where I craft fluid applications using cutting-edge tools like Jetpack Compose and Kotlin Multiplatform. My true passion lies in forging connections between diverse platforms and exploring the limitless possibilities of technology.