Sagar Khurana, Associate Android Engineer at GetYourGuide, breaks down strategies for writing efficient composables in this multi-part series. From understanding state in Compose to techniques for tracking recompositions, this comprehensive guide arms you with the knowledge to build faster, more reliable apps.
{{divider}}
Jetpack Compose takes Data as input and renders the UI. Every time there is a change in data, Compose runtime keeps track and notifies the composables who are listening to the data change.
But what if I told you that your code is making Compose Runtime do more work than it is supposed to do? That's the goal of this series - explain how the Compose runtime works, what causes it to be inefficient, and how to fix it.
First, letâs understand what a âstate readâ means in Compose. Whenever the composable is listening to a snapshot state, any change in the state will be reflected by the composable. But how does it work under the hood?
Changes in the value of a snapshot state propagate as follows:
Hereâs a video illustration to explain
â
A Composable function can be restartable, skippable, or both. But wait, what do restartable and skippable mean exactly?
Restartable
Serves as a scope to start the recompositions, also known as restart scope.
Skippable
If all parameters of the composable are stable (don't worry, we will come back to stability later in the next article), the Compose Runtime can decide to skip its recomposition following a state change. But if one or more parameters of the composable are unstable, then that composable is non-skippable. This is because Compose has no way of knowing if the parameters are the same or not. So, by default, all composables are skippable. If you think about it, it makes sense - a slight decrease in performance is acceptable, as opposed to incorrect or buggy recomposition.
â ď¸Composables that don't return Unit are neither restartable or skippable. They are value producers and should force their parents to recompose upon change.
If you take a closer look at the code sample mentioned above, you'll be wondering if the Nearest restartable function is ContactRow and not Row because Row is an inline Composable function. Functions like function Row, Column, or Box are neither restartable nor skippable as they are inline functions.
â
Now, we've understood what the restart scope is, the meaning of recomposition, and how it happens. Let's see how we can track the recompositions
There are primarily two ways to detect how many times your views go through recomposition:
Layout Inspector: Layout inspector is a tool in Android Studio, which is quite useful for counting/tracking recomposition and skipped recompositions.
How to use a Layout Inspector?
I'm using Android Studio Iguana | 2023.2.1 Canary 2. Steps to launch the Layout Inspector:
A video illustrating how to use Layout Inspector
In the video above, there is one Button and Text. On click of the Button, the text fades in and fades out. As I'm clicking the button, you can see the recomposition count for Text goes from 1 -> 26 and the skipped recomposition count from 1 -> 2. The first column in the Layout Inspector shows the recomposition count, and the second column shows the skipped recompositions. But let's take a step back and try to understand how Compose views are rendered and how Jetpack Compose phases knowledge can help in optimizing the process.
Each frame that is drawn on the screen goes through different phases. Compose renders its composables through three steps:
Itâs virtually possible that Compose might execute three phases in each frame. This is a unidirectional flow that happens from composition to drawing to produce a frame.
In this phase, Compose goes through composables and builds a tree-like data structure.
â ď¸ State reads happening in this phase for the @Composable function or lambda block might potentially affect the subsequent phases. Depending on the result of the composition, Compose UI runs the layout and drawing phases. It might skip these phases if the content remains the same and the size and the layout won't change.
Take input as a UI tree and place it in the 2D space. The phase consists of two steps: measurement and placement. Steps include the following
â
Measure Step: Runs the lambda passed to the Layout composable, the MeasureScope.measure method of the LayoutModifier interface and so on.
Placement Step: The lambda block of Modifier.offset { ⌠}, and so on.
â ď¸ State reads affecting the Layout phase might potentially affect the drawing phase as well
State read affecting the drawing code affects the drawing phase. Common examples include Canvas(), Modifier.drawBehind, and Modifier.drawWithContent.
â
What's the point?
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. Basically, meaning keeping the affected scope to the lowest scope possible. But developers must know as well how to use this to their advantage. Let's have a look at some problem sets, what's their effect, and how can we fix it?
Code Explanation
In this code, we have a Column with a Button and Text. On Click of Button, inversing the value of isShown Boolean, which then animates the offset of the Text.Â
â
Behavior
Looks all good, right? But no. Why is Text recomposing more than 25+ times when we are only changing the offset of the Text đ¤
Cause
Composable modifier's param is changing frequently, causing the Composable to recompose.
Text is being recomposed more than 25+ times, as animateIntAsState is changing value rapidly for each value. Value is being further passed to the Modifier.offset(...) and being read in the Composition phase i.e. Modifier.offset(...), which then returns a new instance of Modifier, causing the entire Text Recomposition.
Solution
Localize the phase to the lowest possible level. In this case, the Offset changes the placement of the Composable. So we can use the localization Layout phase by using the offset modifier lambda version where the lambda block we provide to the modifier is invoked during the layout phase (specifically, during the layout phase's placement step).
â
Result
Now, changing the Offset of Text is not causing recompositions because the Composition phase can be left out.
â
Code Explanation
We have this code below, a Column with Button and Text. On Click of Button, it inverts the value of the isShown, which then changes the opacity of the Text.
â
Behavior
Looks all good, right? But again no. Why Text is being recomposed for more than 25+ times when we are only changing the alpha of the Text from 0 -> 0.5 and vice versa đ¤
â
Cause
Composable modifier's param is changing frequently, causing the Composable to recompose. Text is being recomposed more than 25+ times, as animateFloatAsState is changing value rapidly for each value which is being further passed to the Modifier.alpha(...) and this value is being read in the Composition phase, i.e Modifier.alpha(...), and returning a new instance of Modifier, causing the entire Text recomposition.
Solution
You guessed it âď¸ Localizing the phase to the lowest possible level. In this case, the animationSpec only changes the value of the alpha. So we can localize it to the drawing phase by using the graphicsLayer modifier instead of alpha modifier which triggers the recompositions for the composable.
â
Result
Now, on change in alpha of Text is not causing the recompositions đĽł
At GetYourGuide,adapting these changes in our code base to make our Compose code performant đ That's it for now. In the next article, we will learn about what it means for stability in Compose.Â
You can find the samples mentioned above in this Github Repo - hello sagar/ComposePerformance. Special thanks to Philipp Nowak, Shreyas Patil, Himanshu Singh, Kasem SM, Bianca Stan, Benedict Pregler, Milan Jovic, and Volodymyr Machekhin for reviewing the article.
- Compose Performance: Hunting for unnecessary recomposition