Since Google announced Jetpack Compose stable 1.0, many companies are getting started to adopt Jetpack Compose into their projects. According to Google’s What developers are saying, Jetpack Compose increases their productivity and code quality.
Jetpack Compose has a dedicated optimization system but it's essential to understand the rendering mechanism of Jetpack Compose for improving your app performance properly.
In this article, you will learn about the overall rendering process of Jetpack Compose and how to optimize your app performance following Jetpack Compose guidelines that were built by Stream’s Compose team.
If you want to learn more about the entire guidelines, check out Stream’s Compose SDK Guidelines.
Before you dive in, let's see how Jetpack Compose compiler renders UI elements in the runtime.
Jetpack Compose Phases
Jetpack Compose renders a frame following the three distinct phases:
- Composition: This is the first phase of the rendering. Compose run composable functions and analyze your UI information.
- Layout: Compose measure and commutates UI information on where to place the UI elements, for each node in the layout tree.
- Drawing: UI elements will be drawn on a Canvas.
Most Composable functions follow the phases above always in the same order.
Let’s assume that you need to update the UI elements, such as the size, and color of your layout. Since the Drawing phase is already completed, Compose needs to run the phases from the first to update with the new value; this process is called Recomposition:
Recomposition is the process of running your composable functions again from the Composition phase when the function’s inputs change.
However, recomposing the entire UI tree and elements is expensive computations. You can imagine that you should update entire items on RecyclerView when you need to update a single item.
To optimize recomposition, Compose runtime contains the smart optimization system, which skips all functions for lambdas that don’t have changed parameters and eventually, Compose can recompose efficiently.
For more information, check out Jetpack Compose Phases.
Now, let’s see how you can optimize the recomposition expenses.
1. Aim to Write Stable Classes
Jetpack Compose has its dedicated runtime and it decides which Composable function should be recomposed when their inputs or states change.
To optimize runtime performance, Jetpack Compose relies on being able to infer if a state that is being read has changed.
Fundamentally, there are three stability types below:
- Unstable: These hold data that is mutable and do not notify Composition upon mutating. Compose is unable to validate that these have not changed.
- Stable: These hold data that is mutable, but notify Composition upon mutating. This renders them stable since Composition is always informed of any changes to state.
- Immutable: As the name suggests, these hold data that is immutable. Since the data never changes, Compose can treat this as stable data.
Now let’s see how those stability types work with real-world examples.
Example of The Real-World Implications
If Compose is able to guarantee stability, it can grant certain performance benefits to a Composable, chiefly, it can mark it as skippable.
Let's create pairs of classes and Composables and generate a Compose compiler report for them. For now, you only need to care about the implications and not the mechanics, so you'll just analyze the results.
Let's create a stable class and Composable function like the example below:
12345678910data class InherentlyStableClass(val text: String) @Composable fun StableComposable( stableClass: InherentlyStableClass ) { Text( text = stableClass.text ) }
Now, let's generate compiler reports for the example above and analyze the results:
stable class InherentlyStableClass { stable val text: String <runtime stability> = Stable } restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun StableComposable( stable stableClass: InherentlyStableClass )
The compiler reports will generate the results above for the StableComposable
composable function.
What does this tell us?
First off, you see that because the data class has all of its (in this case a single) parameters marked as stable, its runtime stability is deemed to be stable as well.
This has implications for the Composable function, which is now marked as:
-
Restartable: Meaning 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.
-
Skippable: Since the only parameter our Composable uses as state is stable, Compose is able to infer when it has or has not changed. This makes Compose Runtime able to skip recomposition of this Composable when its parent scope recomposes and all the parameters it uses as state remain the same.
Now, let's create an unstable class and Composable function like the example below:
12345678910data class InherentlyUnstableClass(var text: String) @Composable fun UnstableComposable( unstableClass: InherentlyUnstableClass ) { Text( text = unstableClass.text ) }
Now, you generate a Compose Compiler report again:
unstable class InherentlyUnstableClass { stable var text: String <runtime stability> = Unstable } restartable scheme("[androidx.compose.ui.UiComposable]") fun UnstableComposable( unstable unstableClass: InherentlyUnstableClass )
The state classes and Composables perform the same job, but not equally well. Even though 90% of what makes them is identical, because the example is using an unstable class we have lost the ability to skip this composable when necessary.
For smaller Composables that do not do much of anything other than calling other Composables, this may not be such a worrisome situation. However, for larger and more complex Composables, this can present a significant performance hit.
Note: Composables that do not return
Unit
will be neither skippable nor restartable. It is understandable that these are not restartable as they are value producers and should force their parents to recompose upon change.
2. Rules for Writing classes
You have inferred your desire for stability. Mostly this means you should aim for immutability as gaining stability through notifying composition requires a lot of work, such as was done by the creation of the MutableState<T>
class.
1. Do not use var
s as properties inside state holding classes
As these are mutable, but do not notify composition, they will make the composables which use them unstable.
Do:
1data class InherentlyStableClass(val text: String)
Don't:
1data class InherentlyUnstableClass(var text: String)
2. Private properties still affect stability
As of the time of writing, it is uncertain if this is a design choice or a bug, but let's slightly modify our stable class from above.
1234data class InherentlyStableClass( val publicStableProperty: String, private var privateUnstableProperty: String )
The compiler report will mark this class as unstable:
unstable class InherentlyStableClass { stable val publicStableProperty: String stable var privateUnstableProperty: String <runtime stability> = Unstable }
Looking at the results, it's fairly obvious that the compiler struggles here. It marks both individual properties as stable, even though one is not, but marks the whole class as unstable.
3. Do not use classes that belong to an external module to form state
Sadly, Compose can only infer stability for classes, interfaces, and objects that originate from a module compiled by the Compose Compiler. This means that any externally originated class will be marked as unstable, regardless of its true stability.
Let's say you have the following class which comes from an external module and is therefore unstable:
123456class Car( val numberOfDoors: Int, val hasRadio: Boolean, val isCoolCar: Boolean, val goesVroom: Boolean )
A common way to build a state using it would be to do the following:
1234data class RegisteredCarState( val registration: String, val car: Car )
However, this is troublesome. Now you've made our state class unstable and therefore unskippable. This could potentially cause performance issues.
Luckily there are multiple ways to get around this.
If you only need a few properties of Car
to form RegisteredCarState
, you may simply flatten it as follows:
12345data class RegisteredCarState( val registration: String, var numberOfDoors: Int, var hasRadio: Boolean )
However, this may not be appropriate in cases where you need the whole object with all of its properties.
In such cases, you may create a local stable counterpart such as:
123456class CarState( val numberOfDoors: Int, val hasRadio: Boolean, val isCoolCar: Boolean, val goesVroom: Boolean )
The two are identical, but CarState
is stable.
Because users might need to convert to and from these classes depending on which architectural layer they are dealing with, you should provide easy mapping functions going both ways such as:
12fun Car.toCarState(): CarState fun CarState.toCar(): Car
4. Do not expect immutability from collections
Things such as List
, Set
, and Map
might seem immutable at first, but they are not and the Compiler will mark them as unstable.
Currently, there are two alternatives, the more straightforward one includes using
Kotlin's immutable collections. However, these are still pre-release and might not be viable.
The other solution, which is a technical hack and not officially advised but used by the community, is to wrap your lists and mark the wrapper class as @Immutable
.
1234@Immutable data class WrappedList( val list: List<String> = listOf() )
Here the compiler still marks the individual property as unstable but marks the whole wrapper class as stable.
stable class WrappedList { unstable val list: List<String> }
Currently, neither of the two solutions are ideal.
5. Flows are unstable
Even though they might seem stable since they are observable, Flow
s do not notify composition when they emit new values. This makes them inherently unstable. Use them only if absolutely necessary.
6. Inlined Composables are neither restartable nor skippable
As with all inlined functions, these can present performance benefits. Some common Composables such as Column
, Row
, and Box
are all inlined. As such this is not an admonishment of inlining Composables, just a suggestion that you should be mindful when inlining composables, or using inlined composables and be aware of how they affect the parent scope recomposition.
You will cover this in more detail in future sections.
3. Hoist state properly
Hoisting state is the act of creating stateless Composables. The formula is simple:
- All necessary states should be passed down from the Composable's caller.
- All events should flow upwards to the source of the state.
Let's create a simple example.
123456@Composable fun CustomButton(text: String, onClick: () -> Unit) { Button(onClick = onClick) { Text(text = text) } }
Next, host it inside set content.
123456789setContent { var count by remember { mutableStateOf(0) } CustomButton(text = "Clicked $count times") { count++ } }
Due to state hoisting, our composable is well-behaved, it follows the unidirectional flow, and is more testable.
However, this doesn't make us completely safe, as we can easily misuse this pattern in more complex scenarios.
4. Don't read the state from a too high scope
Let's presume you have a slightly more complex state holder:
1234class StateHoldingClass { var counter by mutableStateOf(0) var whatAreWeCounting by mutableStateOf("Days without having to write XML.") }
And it's being used in the following manner:
123456@Composable fun CustomButton(count: Int, onClick: () -> Unit) { Button(onClick = onClick) { Text(text = count.toString()) } }
In our hypothetical scenario this state holder is hosted inside a ViewModel
which is a common practice, and read in the following manner:
12345678910setContent { val viewModel = ViewModel() Column { Text("This is a cool column I have") CustomButton(count = viewModel.stateHoldingClass.counter) { viewModel.stateHoldingClass.counter++ } } }
At first glance, this might seem perfectly fine, you might think that because the property StateHoldingClass.counter
is being used as a CustomButtom
parameter that means that only CustomButton
gets recomposed, however this is not the case.
This counts as a state read inside Column
, meaning that the whole Column
now has to be recomposed. But it doesn’t end here. Since Column
is an inline function, this means that it will trigger the recomposition of its parent scope as well.
Luckily you have an easy way of avoiding this, the answer is to lower state reads.
Let's rewrite our Composable in the following manner:
123456@Composable fun CustomButton(stateHoldingClass: StateHoldingClass, onClick: () -> Unit) { Button(onClick = onClick) { Text(text = stateHoldingClass.counter.toString()) } }
And change the call site to:
12345678910setContent { val viewModel = ViewModel() Column { Text("This is a cool column I have") CustomButton(stateHoldingClass = viewModel.stateHoldingClass) { viewModel.stateHoldingClass.counter++ } } }
Now, the state read is happening inside CustomButton
, which means that we will only recompose the contents of said Composable. Both Column
and its parent scope are spared unnecessary recomposition in this scenario!
5. Avoid running expensive calculations unnecessarily
Let's create the following Composable:
1234567891011121314@Composable fun ConcertPerformers(venueName: String, performers: PersistentList<String>) { val sortedPerformers = performers.sortedDescending() Column { Text(text = "The following performers are performing at $venueName tonight:") LazyColumn { items(items = sortedPerformers) { performer -> PerformerItem(performer = performer) } } } }
This is simple Composable, it displays the name of a venue, along with the performers who are performing at that venue. It also wants to sort that list so that the readers have an easier time finding if a performer they are interested in is performing.
However, it has one key flaw. If the venue gets changed, but the list of performers stays the same, the list will have to be sorted again. This is a potentially very costly operation. Luckily it's fairly easy to run it only when necessary.
12345678910111213141516@Composable fun ConcertPerformers(venueName: String, performers: PersistentList<String>) { val sortedPerformers = remember(performers) { performers.sortedDescending() } Column { Text(text = "The following performers are performing at $venueName tonight:") LazyColumn { items(items = sortedPerformers) { performer -> PerformerItem(performer = performer) } } } }
In this example above, you've used remember
that uses performers
as a key to calculate the sorted list. It will recalculate only when the list of performers changes, sparing unnecessary recomposition.
If you have access to the original State<T>
instance, you can reap additional benefits by deriving state directly from it, such as in the following example (please note the change function signature):
12345678910111213141516@Composable fun ConcertPerformers(venueName: String, performers: State<PersistentList<String>>) { val sortedPerformers = remember { derivedStateOf { performers.value.sortedDescending() } } Column { Text(text = "The following performers are performing at $venueName tonight:") LazyColumn { items(items = sortedPerformers.value) { performer -> PerformerItem(performer = performer) } } } }
Here, Compose does not only skip unnecessary calculations, but is smart enough to skip recomposing the parent scope since you're only changing a locally read property and not recomposing the whole function with new parameters.
6. Defer reads as long as possible
This section is a simplification of the example provided in the official documentation seen in Defer reads as long as possible.
You'll create a small composable that is meant to be animated by sliding out:
12345678910111213@Composable fun SlidingComposable(scrollPosition: Int) { val scrollPositionInDp = with(LocalDensity.current) { scrollPosition.toDp() } Card( modifier = Modifier.offset(scrollPositionInDp), backgroundColor = Color.Cyan ) { Text( text = "Hello I slide out" ) } }
Much like in the original example, you've tied it to the scroll position. But since LazyColumn
doesn't take ScrollState
, you'll slightly modify our Composables to the following:
123456789101112131415161718192021222324252627282930313233343536373839404142@Composable fun ConcertPerformers( scrollState: ScrollState, venueName: String, performers: PersistentList<String>, modifier: Modifier = Modifier ) { Column(modifier = modifier) { Text( modifier = Modifier.background(color = Color.LightGray), text = "The following performers are performing at $venueName tonight:" ) Column( Modifier .weight(1f) .verticalScroll(scrollState) ) { for (item in performers) { PerformerItem(performer = item) } } } } @Composable fun PerformerItem(performer: String) { Card( modifier = Modifier .padding(vertical = 10.dp) .background( color = Color.LightGray, ) .wrapContentHeight() .fillMaxWidth() ) { Text( modifier = Modifier.padding(10.dp), text = performer ) } }
Now, you can build this layout as follows:
12345678910111213141516setContent { val scrollState = rememberScrollState() Column( Modifier.fillMaxSize() ) { SlidingComposable(scrollPosition = scrollState.value) ConcertPerformers( modifier = Modifier.weight(1f), scrollState = scrollState, venueName = viewModel.venueName, performers = viewModel.concertPerformers.value ) } }
By doing this, you run into the same problem you had previously. Read the state high and recompose everything inside setContent
. You might be tempted to solve it the same way as you did previously, by passing the whole ScrollState
as a parameter, but there's another very handy way.
You can introduce a lambda to defer the state read:
12345678910111213@Composable fun SlidingComposable(scrollPositionProvider: () -> Int) { val scrollPositionInDp = with(LocalDensity.current) { scrollPositionProvider().toDp() } Card( modifier = Modifier.offset(scrollPositionInDp), backgroundColor = Color.Cyan ) { Text( text = "Hello I slide out" ) } }
The scrollPositionProvider
lambda does not change, only the result changes when an invocation occurs. This means that
the state read is now happening inside of SlidingComposable
and you do not cause parent recomposition.
But wait, there's an extra step of optimization available!
Compose draws UI in phases:
- Composition (runs your Composables)
- Layout (measures the Composables and defines their placement)
- Drawing (Draws the elements on the screen)
Skipping any of these phases - when possible - will lessen the overhead.
In our example, you still cause recomposition, meaning that you go through all three steps. However, since you have not changed the composition and are still drawing the same elements on a screen, you should skip this step.
1234567891011121314@Composable fun SlidingComposable(scrollPositionProvider: () -> Int) { Card( modifier = Modifier.offset { IntOffset(x = scrollPositionProvider(), 0) }, backgroundColor = Color.Cyan ) { Text( text = "Hello I slide out" ) } }
What's the big change here? Modifier.offset(offset: Density.() -> IntOffset)
is called during the layout phase, so you are able to completely skip the composition phase by using it; this, in turn, provides a significant performance benefit.
Conclusion
In this article, you learned the overall rendering process of Jetpack Compose and how to optimize your app performance following Stream’s Jetpack Compose guidelines and best practices.
Stream’s Jetpack Compose team aims to build performant chat APIs for providing the best developer experience to SDK users. If you’re interested in learning more about our guidelines, check out the Compose SDK Guidelines.
You can find the author of this article on Twitter @github_skydoves and @TolicMarin, if you have any questions or feedback. If you’d like to stay up to date with Stream, follow us on Twitter @getstream_io for more great technical content.
As always, happy coding!