Performance With Jetpack Compose — Part 1

Udit Verma
ProAndroidDev
Published in
8 min readJun 26, 2023

--

Generated using Midjourney

Part 2 of this blog post series can be accessed here.

Jetpack Compose is a modern UI toolkit for building native Android apps using Kotlin. It provides a declarative approach to building user interfaces, allowing developers to easily create and manage UI components. It's fairly new but still looks very powerful and allows for a lot of flexibility when it comes to creating UI on Android.

Even though creating a basic UI in compose is no rocket science, it is important to have some basic understanding of how Compose framework works under the hood and what are some of the best practices we can adopt. Having this basic knowledge will empower us to create performant UIs and also be in a better position to debug in case something is not working or performing as we expected it to.

By the end of this two-part blog series, you will see the UI, with the exact same functionality, recomposing less than 99% of the time what it was doing originally.

This part talks about

  1. Phases of Compose
  2. Remember Block
  3. Stable Parameters and Skippable Composables

Phases of Compose

Every composable (with a few exceptions) follows these 3 phases before it can be rendered on the screen:

If you are already aware of these phases, feel free to skip to the next section.

Source: https://developer.android.com/jetpack/compose/phases
  1. Composition

The first phase is the composition which decides What UI is to be shown. This phase is responsible for creating your UI tree. To be able to create this UI tree, compose reads the state and decides accordingly.

2. Layout

Once the composition phase is over, the second phase is the layout. This phase decides Where to place the UI. During this phase, the size and position of the UI elements are calculated based on the constraints that are passed to them.

3. Draw

The 3rd and final phase takes care of How the UI is rendered on the screen. This is where basically the elements are drawn on the canvas and finally visible on the screen.

These three phases virtually run every frame in the same order (keeping in mind the exceptions) to be able to keep the UI in sync with the state. Compose is smart enough to be able to skip any of the phases for any composable if it knows the output will be the same as the last time. This is a very important optimisation for the framework and it is able to achieve this by keeping track of state reads.

For example, if you read a state variable while composing the UI (composition phase), whenever that state changes, the composition phase and the subsequent phases for that composable will be scheduled.

Those composables with no change in state will be skipped entirely for all three phases.

📖 Before concluding this section, lets quickly talk about the exceptions to these phases. So any composable that is build on top of the SubcomposeLayout layout does not follow these phases exactly. Some notable examples being LazyColumn, LazyRow and BoxWithConstraints. For simplicity, this blog post will not cover them, but you can read more here.

Compose Performance

For the purpose of this entire blog post series, we will use a simple screen build in compose. There is a scrollable list of items, a scroll position indicator and finally a scroll-to-top button, all laid out in a column.

  • Items should be sorted based on their id
  • The scroll to top button should only be visible after the 50% scroll threshold.
  • The scroll indicator indicates the current scroll position and moves horizontally.

The initial code for the UI shown above can be found here.

We will use logcat logs to understand the current behaviour and also to verify if our fixes worked as expected or not.

📌 Source code for every optimisation mentioned in the blog post is available in this repo. Each step has its folder (step0, step1, etc..). Step0 is the starting point

If you try and run step0 of this code and look at the logs, you will notice a lot of tasks being repeated as you scroll through the list. Our UI is definitely doing much more processing than necessary. Let's see if we can reduce any of this

📌 For ease of understanding, the logs in this example are tagged. You can filter for all relevant logs using “ComposePerformance”. Logs can further be filtered based on whether there is a calculation happening (ComposePerformance-ReAllocation) or a recomposition happening (ComposePerformance-Recomposition)

Introducing remember blocks

As we read in the first section, a composable can be composed many times based on state changes. This basically means all the code that we write inside a composable function can be executed multiple times. While this is important to keep the UI up-to-date, it also adds to the possibility of redundant code execution which was not required to execute every time any state changed.

To prevent blocks of code from being executed every time a composable is composed, we use remember block. Any code inside the remember block is executed only if the key parameter changes.

val data = remember (key = key1) {
// these lines will only be executed when the value of key1 changes
// irresepetive how many times the composable is composed.
}

Let's see how can we use this remember block to prevent some unnecessary code execution and object creation.

If you filter logcat with ComposePerformance-ReAllocationtag, you will see all the allocations being done some of which can be prevented by placing them in the remember block:

Firstly, inside the parent composable, we are generating a list of 100 strings to be shown in the list. This list should only be created once.

Before

Logger.d(
message = "Recreating item list",
filter = LogFilter.ReAllocation
)
val random = Random(10)
val items = IntRange(0, 100).map {
val randomNumber = random.nextInt().absoluteValue % 200
Item(
desc = "[$randomNumber] Item with index = $it",
id = randomNumber
)
}

After

val items = remember {
Logger.d(
message = "Recreating item list",
filter = LogFilter.ReAllocation
)
val random = Random(10)
IntRange(0, 100).map {
val randomNumber = random.nextInt().absoluteValue % 200
Item(
desc = "[$randomNumber] Item with index = $it",
id = randomNumber
)
}.sorted()
}

Similarly, we can refactor to include a few more code blocks within the remember block

Before

Logger.d(
message = "Recalculating scroll progress",
filter = LogFilter.ReAllocation
)
val scrollProgress = scrollState.value / (scrollState.maxValue * 1f)
Logger.d(
message = "Recalculating showScrollToTopButton",
filter = LogFilter.ReAllocation
)
val showScrollToTopButton = scrollProgress > .5

After

val scrollProgress = remember(scrollState.value, scrollState.maxValue) {
Logger.d(
message = "Recalculating scroll progress",
filter = LogFilter.ReAllocation
)
scrollState.value / (scrollState.maxValue * 1f)
}
val showScrollToTopButton = remember(scrollProgress) {
Logger.d(
message = "Recalculating showScrollToTopButton",
filter = LogFilter.ReAllocation
)
scrollProgress > .5
}

Another place where we could use remember to optimise a bit more would be inside the ScrollPositionIndicator component

Before

Logger.d(
message = "Recalculating progressWidth",
filter = LogFilter.ReAllocation
)
val progressWidth = maxWidth - (8.dp)

After

val progressWidth = remember(maxWidth) {
Logger.d(
message = "Recalculating progressWidth",
filter = LogFilter.ReAllocation
)
maxWidth - (8.dp)
}

this will ensure that progressWidth is only recalculated when the value of maxWidth changes.

Let’s try running now and verify the logs

As you can see, the list is no longer being created again and again and neither is progressWidth being calculated. We are still doing scroll-related calculations because the scroll value is getting updated constantly.

The source code for this step can be accessed here.

Stable Parameters and Skippable Composable

While discussing phases of composable, we saw that compose runtime does certain optimisations and skips re-composing certain sections of the UI tree based on the state changes. If it can detect that for a particular composable, no state has changed, then it can skip its recomposition for much better performance.

While compose runtime is smart enough to do this, it needs certain conditions to be fulfilled before it can correctly decide whether to skip composition for a certain composable or not

  1. Parameters

When we pass parameters to a composable function, they could be of any type. More importantly, they could be immutable or mutable; stable or unstable. Let’s look at each of these terms

1.1 Immutable params — Read-only values. The only way a new state can be passed into these parameters is by creating a new instance of these types. This ensures compose runtime can figure out whether the value of this parameter has changed or not. A data class with only val properties is a good example.

1.2 Mutable params — Variables which allow both reading and writing. Since we can edit the values in these types of variables without having to create a new instance, compose runtime can never be sure if something has changed or not within these parameters. Data class with one or more var is a good example of this type.

1.3 Stable params — Variables which are mutable but notify the compose runtime of any state change so that it can keep track. MutableState is a good example of this type of parameter.

2. Skippable Composable

For a composable to be skippable the compose runtime should be able to figure that out. This basically boils down to all parameters of that composable being Immutable or Stable.

📌 Compose treats all collection classes (List, Map, etc.) as unstable params. The same applies to all data classes declared in a module with no compose compiler plugin as dependency.

To find out whether a composable is skippable or not, we can use compiler reports. For more details on how to use them, please read here. For more on Compose Stability, you may refer to this blog post.

Coming back to our example, if you notice the ItemList function, none of the values changes as we scroll through the list.

@Composable
private fun ItemList(
modifier: Modifier = Modifier,
items: List<Item>,
scrollState: ScrollState = rememberScrollState()
)

Filtering through the logs, however, we see Recomposing ItemList being printed on every scroll change

Let’s take a look at the output of compiler reports for this function.

restartable scheme("[androidx.compose.ui.UiComposable]") fun ItemList(
stable modifier: Modifier? = @static Companion
unstable items: List<Item>
stable scrollState: ScrollState? = @dynamic rememberScrollState(0, $composer, 0, 0b0001)
)

We see one unstable param, the items list. Even though the list is of immutable type, compose treats it as an unstable parameter. To work around this, we shall wrap it in an immutable data class

// data class with only val types are immutable by default. 
// Had that not been the case, adding the @immutable or @stable annotations are
// another ways of letting the compose runtime know when it cannot itself
// figure out.
@Immutable
data class ItemHolder(
val items: List<Item>
)

Now the updated composable becomes

@Composable
private fun ItemList(
modifier: Modifier = Modifier,
itemHolder: ItemHolder,
scrollState: ScrollState = rememberScrollState()
)

and the compiler reports for this function says

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ItemList(
stable modifier: Modifier? = @static Companion
stable itemHolder: ItemHolder
stable scrollState: ScrollState? = @dynamic rememberScrollState(0, $composer, 0, 0b0001)
)

Now all params are marked as stable. This in turn marks the function as skippable which basically means it can now be skipped if no change in state is detected. Let's try running the code and look at the logs again.

And we have got rid of that extra recomposition!

📌 Instead of a data class, You can use Kotlinx immutable collections too

The source code for this step can be accessed here.

This brings us to the end of the first blog in this two-part series. There are still some interesting optimisations we can do further improve our UI. Continue reading as we further optimise this UI in part 2 here.

As always, thanks for reading and do leave your feedback or queries in the comments section.

--

--