In modern Android development, Jetpack Compose is one of the most popular UI toolkits and you can increase your productivity with Compose. In this article, you’ll learn how to optimize your app performance following Stream’s Jetpack Compose guidelines.
•Published: Aug 23, 2022
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:
Now, let's generate compiler reports for the example above and analyze the results:
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:
Now, you generate a Compose Compiler report again:
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
Unitwill 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
1. Do not use
vars as properties inside state holding classes
As these are mutable, but do not notify composition, they will make the composables which use them unstable.
data class InherentlyStableClass(val text: String)
data 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.
The compiler report will mark this class as 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:
A common way to build a state using it would be to do the following:
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:
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:
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:
fun Car.toCarState(): CarState fun CarState.toCar(): Car
4. Do not expect immutability from collections
Things such as
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
Here the compiler still marks the individual property as unstable but marks the whole wrapper class as stable.
Currently, neither of the two solutions are ideal.
5. Flows are unstable
Even though they might seem stable since they are observable,
Flows 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
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.
Next, host it inside set content.
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:
And it's being used in the following manner:
In our hypothetical scenario this state holder is hosted inside a
ViewModel which is a common practice, and read in the following manner:
At first glance, this might seem perfectly fine, you might think that because the property
StateHoldingClass.counteris 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:
And change the call site to:
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:
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.
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):
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:
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:
Now, you can build this layout as follows:
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:
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.
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.
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.
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!