Overcoming Common Performance Pitfalls in Jetpack Compose

Overcoming Common Performance Pitfalls in Jetpack Compose

Some best practices

·

11 min read

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.

💡
As of today, while writing this blog, “Strong Skipping Mode” is experimental, and is currently under testing. It can be a game changer shortly and it might be possible that we do not need to worry about most of the things (if enabled in code) that will be discussed in this blog. But since then (or maybe even after that), these rules will be very crucial for development in Compose. Read more about the Strong Skipping Mode here.

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.

Did you find this article valuable?

Support Pushpal Roy by becoming a sponsor. Any amount is appreciated!