Article
Basic Intelligent Recomposition in Android Compose
July 29, 2024
In the Android Compose UI paradigm, recompositions drive UI changes. So if you are using Android Compose, then your app is recomposing, but you may not be taking full advantage of Compose’s capacity for intelligent recomposition. That is OK — it isn’t necessarily obvious how to do so — this article sets out to teach you a bit about it.
Why should you care?
A crucial component to a good app experience is buttery smooth UI. Too many recompositions can cause dropped frames and that is public enemy number one to buttery smooth UI. So it behooves us to be intelligent with our recompositions. Often times there is an animating state that requires recomposing a single UI component many times over a period of time. An app that takes advantage of intelligent recomposition will exclusively recompose just the animating component. In the worst case, a poorly written UI could completely rebuild its entire Composable hierarchy every time that animating component updates. This could be the type of error that results in dropped frames — let’s learn how to avoid that.
The Basics
First let’s get the definition of recomposition:
Recomposition is the process of calling your composable functions again when inputs change. This happens when the function’s inputs change. When Compose recomposes based on new inputs, it only calls the functions or lambdas that might have changed, and skips the rest. By skipping all functions or lambdas that don’t have changed parameters, Compose can recompose efficiently.
This makes it clear that Compose monitors inputs to composable functions and “recomposes” the ones that are updated because it assumes that if its inputs have changed, it’s embedded logic needs to be rerun in order to properly re-render itself. This leads to two basic questions that we need to answer:
- The hierarchy of composables are selectively recomposed. So what is special about a composable function that allows it to be added to the composable hierarchy?
- Changes in a composable’s inputs inspire recompositions. What constitutes a composable input?
Let’s go over these two things…
Composable Functions
Each function or lambda annotated with @Composable
creates a RecomposeScope
. This nifty compose runtime component creates a recomposition scope within a hierarchy of recomposition scopes to represent a living screen. If an input to a RecomposeScope
is changed, the scope will be invalidated and its content will be rerun with the updated inputs. If an invalidated RecomposeScope
contains child RecomposeScope
s, Compose will skip recomposing those scopes if they don’t depend on the changing input. At the same time, if a child RecomposeScope
is invalidated, it isn’t necessary that its parent is too. It should be known that inline
functions (e.g. Column
and Row
) will not create a new RecomposeScope
. Below you can see the hierarchy of RecomposeScope
s created by SuperSpecialComponent
:
Both “Button Scope” and “Super Special Component Scope” will observe their own input changes and rebuild themselves whenever necessary. There is no “Column Scope” because Column
is an inline function, its content becomes a part of “Super Special Component Scope”.
If SuperSpecialComponent
receives a state change that the Button
doesn’t care about, then the “Button Scope” can be skipped for recomposition even though its parent RecomposeScope
is invalidated.
If the Button
’s inputs change but the “Super Special Component Scope” doesn’t read any of those inputs, SuperSpecialComponent
will not even be considered for recomposition because a child RecomposeScope
being invalidated does not invalidate the parent.
This behavior illuminates an important part of the Compose UI paradigm: nodes of the composable hierarchy can recompose independently if you do it right.
Now that we have a basic understanding of how to create a RecomposeScope
hierarchy, let’s discuss what constitutes a composable’s inputs.
Composable Inputs
The obvious inputs to a composable are its parameters. If a composable’s parameters change it will recompose. However, Compose runtime won’t notice just any state change; it requires a special observable type called State
. This is a magical Compose runtime component that allows Compose to listen for value changes in order to recompose when new data is received. State
related updates are the primary driver of recompositions. This is why you commonly see ViewModel
data flows converted into state via Flow<T>.collectAsState()
, and state holders emit state backed by some sort of State
. If you want your UI to react to your data updates, that data must be backed by State
!
A crucial point here is that a composable inputs aren’t just its parameters, it is also any State
read that occurs within its RecomposeScope
. For example, Component
below has two inputs that can cause its recomposition, color
and text
:
private fun MainComponent() {
val viewModel: MainViewModel = hiltViewModel()
val text by viewModel.textFlow.collectAsState()
SuperSpecialComponent(
text = text,
)
}
private fun SuperSpecialComponent(
text: String, // Input one
) {
val color: State<Color> = animateColorAsState(...)
Button {
Column {
Text(
text = text,
color = color.value, // Input two
)
...
}
}
}
If textFlow
is updated, then the MainComponent
will be recomposed because it is responsible for reading it’s value. SuperSpecialComponent
will recompose due to its parent’s RecomposeScope
being invalidated, at which point Compose runtime will notice that it depends on text
which has changed. Button
will also recompose because it depends on text
.
Additionally, Button
will recompose each time color
is updated by its backing function animateColorAsState(...)
, while SuperSpecialComponent
will not be recomposed because it never reads color
within its RecomposeScope
.
Now we understand that a composable’s inputs are any State
reads that might occur within its RecomposeScope
, or any parameters it might have that are updated when its parent recomposes. So how can use this information to build a UI that intelligently recomposes.
What can we do?
Based on what we just covered, recomposition scopes should only read State
that they actually depend on. If a component is only reading state to then hand it off to another component, there is probably some optimization that can occur, especially if that state is changing rapidly. So let’s take a look at a bad, good, and great way to handle changing inputs within a composable.
For our example…
We will need some animating State
— for that we will use this composable function that produces aState<Int>
and increments its Int
value every 500 milliseconds.
@Composable
fun produceAnimatingState(): State<Int> {
val animatingState = remember { mutableIntStateOf((0) }
LaunchedEffect(Unit) {
while (coroutineContext.isActive) {
animatingState.intValue++
delay(500)
}
}
return animatingState
}
The Bad Way
Here is an inefficient component that reads State
in too high of a scope.
@Composable
fun ComponentRaw() {
val animatingState = produceAnimatingState()
Row {
Text(
text = animatingState.value.toString(),
)
Spacer(...)
Icon(...)
}
}
When the input to Text
changes it recomposes, that is great! We want Text
to be updated; however, animatingState
is read from within ComponentRaw
‘s RecomposeScope
which invalidates the entire component whenever animatingState
changes.
ComponentRaw
recomposed 83 times. For each of those recompositions each line of logic within the component is evaluated, and each sub-composable is considered for recomposition! The Icon
is correctly skipped , as it is static. Since the Text
is the only state that changed, we’d like the ComponentRaw
recomposition count to be zero. Let’s see how we can do that.
The Solution
We need to wrap the Text
composable that cares about animatingState
‘s value in its own RecomposeScope
. Then, we must defer the reading of animatingState
‘s value until it is within that scope. There are a few ways to do that, but I am going to go over what I will call The Good Way and The Great Way. In the wild you pretty much only see The Great Way — because it is great — but I do think going over The Good Way is worthwhile because it helps illuminate a point.
The Good Way
We can create a new RecomposeScope
as prescribed, and pass theState
directly to the composable and read it within. Our new composable is CoolTextState
, and it simply wraps the Text
that is responsible for reading animatingValue
.
@Composable
fun ComponentState() {
val animatingState = produceAnimatingState()
Row {
CoolTextState(
animatingState = animatingState,
)
Spacer(...)
Icon(...)
}
}
@Composable
fun CoolTextState(animatingState: State<Int>) {
Text(
text = animatingState.value.toString(),
)
}
We can see that we have achieved the desired behavior! ComponentState
never recomposes. The Icon
doesn’t even need to skip recompositions; it is simply never considered for them. Changes to animatingState
’s value only recompose the component that cares.
This example shows that it is the actual read of State
that triggers the recomposition. We can freely pass it around, only to unravel the value where it is actually appreciated. Compose will notice changes in State
and trigger invalidations of the recomposition scopes that care.
This approach is rare because it has a weakness — if you want to provide an Int
that is not contained by a State
, tough luck. Maybe in some places your component animates and in others it uses a static value. In the latter case it would be overkill to use a State
backed value.
So how can we maintain these performance benefits but expect an Int
rather than State<Int>
?
The Great Way
The more common and better option is known as the provider pattern. You still need the new RecomposeScope
, but this time you pass your value through the composable hierarchy with a stable lambda backed by State
. (Note that this is just a shorthand description: all lambdas are technically stable and the stability issue is really related to the data being captured .) The contents of this type of lambda will be intelligently evaluated for changes, and you will experience the exact same performance as ComponentState
, but you will have a more general solution.
@Composable
fun ComponentProvider() {
val animatingState = produceAnimatingState()
Row {
CoolTextProvider(
animatingValueProvider = { animatingState.value },
)
Spacer(...)
Icon(...)
}
}
@Composable
fun CoolTextProvider(animatingValueProvider: () -> Int) {
Text(
text = animatingValueProvider().toString(),
)
}
This is the exact same recomposition behavior as ComponentState
! But if we wanted to, we could still provide a static Int
, like so:
CoolTextProvider(
animatingValueProvider = { 42 },
)
Onwards and Upwards, Efficiently
If you like buttery smooth UI, then you want to take advantage of intelligent recompositions by localizing your State
reads. This will require you to wrap rapidly changing UI components in composable functions in order to build fine grained RecomposeScope
’s. With this beautiful recomposition scope hierarchy, you will need to defer State
reads by using the provider pattern in order to localize those recompositions. With all of that, you will have UI so buttery smooth that your users won’t be able to get a grip.