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
- Phases of Compose
- Remember Block
- 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
Job Offers
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-ReAllocation
tag, 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
- 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.
This article was previously published on proandroiddev.com