What is “donut-hole skipping” in Jetpack Compose?
I recently stumbled on a term that was brought up in a few conversations related to Jetpack Compose. It's being referred to as “donut-hole skipping” . The name certainly intrigued me enough to go down the rabbit hole, or in this case, the donut hole 🍩 Given how early we are in the Jetpack Compose journey, I think it would be valuable to cover some basic concepts to get everyone at the same baseline before we "dough" into the more interesting bits.
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.
At a high level, anytime the inputs or the state of a @Composable
function changes, it would be valuable for the function to be invoked again so that the latest changes are reflected. This behavior is critical to how Jetpack Compose works and is also what makes it so powerful as this reactive nature is a first class citizen of the framework. If I were to oversimplify this (like really oversimplify!), anyone familiar with the classic Android View system might remember a method called invalidate()
that was used to ensure that the latest state of the View
was represented on the screen.
This is effectively what recomposition is responsible for as well with an important nuance - it's much smarter than the previous UI toolkit as it will avoid redundant work when possible using smart optimizations. In addition, this happens automatically so you don't need to call any methods for this to happen. With that said, let's look at some examples of recomposition in action and hopefully it will lead us to the sugary delight optimization that I spoke about at the start of this post.
Maker OS is an all-in-one productivity system for developers
I built Maker OS to track, manage & organize my life. Now you can do it too!
Example 1
@Composable
fun MyComponent() {
val counter by remember { mutableStateOf(0) }
CustomText(
text = "Counter: $counter",
modifier = Modifier
.clickable {
counter++
},
)
}
@Composable
fun CustomText(
text: String,
modifier: Modifier,
) {
Text(
text = text,
modifier = modifier.padding(32.dp),
style = TextStyle(
fontSize = 20.sp,
textDecoration = TextDecoration.Underline,
fontFamily = FontFamily.Monospace
)
)
}
We created a simple composable function called MyComponent
that initializes a state object to hold the value of counter
. This value is rendered by the Text
composable and every time you tap on this text, counter is incremented. What we are interested to see is which parts of this function are reinvoked. In order to investigate this further, we are going to use log statements. But we want to trigger these log statements only when recompositions are happening. This sounds like the perfect use case for SideEffect, a composable function that is reinvoked on every successful recomposition. Since we need to use this in a few places, let's write a helper function that will be useful for this investigation across all the examples. I'd like to credit my friend Sean McQuillan for the code snippet below 🙏
class Ref(var value: Int)
// Note the inline function below which ensures that this function is essentially
// copied at the call site to ensure that its logging only recompositions from the
// original call site.
@Composable
inline fun LogCompositions(tag: String, msg: String) {
if (BuildConfig.DEBUG) {
val ref = remember { Ref(0) }
SideEffect { ref.value++ }
Log.d(tag, "Compositions: $msg ${ref.value}")
}
}
Let's make use of this helper function in our example and give it a spin!
@Composable
fun MyComponent() {
val counter by remember { mutableStateOf(0) }
+ LogCompositions("JetpackCompose.app", "MyComposable function")
CustomText(
text = "Counter: $counter",
modifier = Modifier
.clickable {
counter++
},
)
}
@Composable
fun CustomText(
text: String,
modifier: Modifier = Modifier,
) {
+ LogCompositions("JetpackCompose.app", "CustomText function")
Text(
text = text,
modifier = modifier.padding(32.dp),
style = TextStyle(
fontSize = 20.sp,
textDecoration = TextDecoration.Underline,
fontFamily = FontFamily.Monospace
)
)
}
On running this example, we notice that both MyComponent
& CustomText
are recomposed every time the value of the counter changes. We'll keep this in mind and look at another example so that we can compare the behavior and hopefully derive some insights 🤞🏻
Example 2
@Composable
fun MyComponent() {
val counter by remember { mutableStateOf(0) }
LogCompositions("JetpackCompose.app", "MyComposable function")
+ Button(onClick = { counter++ }) {
+ LogCompositions("JetpackCompose.app", "Button")
CustomText(
text = "Counter: $counter",
- modifier = Modifier
- .clickable {
- counter++
- },
)
+ }
}
We are reusing our previous example with a small difference - we introduce a Button
composable to handle our click logic and moved the CustomText
function inside the scope of the Button
function. We also added a log statement inside the scope of the Button function to check if that lambda is being executed. Let's run this example and monitor the log statements.
Here's where things start to get really interesting. We see that the body of MyComponent
was executed during the very first composition along with the body of the Button
composable and theCustomText
composable. However, every subsequent recomposition only causes the Button
and the CustomText
composable to be invoked again while the body of MyComponent
is skipped altogether. Interesting..... 🤔
Recomposition Scope
In order to understand how Compose is able to optimize recompositions, it is important to take into account the scopes of the composable functions that we are using in our examples. Compose keeps track of these composable scopes under-the-hood and divides a Composable function into these smaller units for more efficient recompositions. It then tries its best to only recompose the scopes that are reading the values that can change. In order to wrap our heads around what this means, let's use this lens and look at both our examples again. This time, we'll take into account the scopes that are available for the Compose runtime to do its book-keeping.
Example 1 with its recomposition scopes
We see that there's a couple lambda scopes at play in the first example i.e the scope of the MyComponent
function and the scope of CustomText
function. Furthermore, CustomText
is in the lambda scope of the MyComponent
function. When the value of the the counter changes, we previously noticed that both these scopes were being reinvoked and here's why -
CustomText
is recomposed because its text parameter changed as it includes the counter value. This makes sense and is probably what you want anyway.MyComponent
is recomposed because its lambda scope captures the counter state object and a smaller lambda scope wasn't available for any recomposition optimizations to kick in.
Now you might wonder what I meant when I said "a smaller lambda scope wasn't available". Hopefully the next example will make this clear!
Example 2 with its recomposition scopes
In this example, we previously noticed that only Button
and CustomText
were reinvoked when the value of counter updated and MyComponent
was skipped altogether. Here are some of our observations when we look at this example -
- Even though the initialization of the counter is in the scope of
MyComponent
, it doesn't read its value, at least not directly in the parent scope. - The
Button
scope is where the value of counter is read and passed to theCustomText
composable as an input
Since the compose runtime was able to find a smaller scope (Button scope) where the value of the counter was being read, it skipped invoking the MyComponent
scope and only invoked the Button
scope (where the value is being read) & the CustomText
scope (as its input changed). In fact, it also skipped invoking the Button
composable (it invoked its scope, not the Button composable itself).
What does a donut have to do with all this?
Let's get to why you opened this article in the first place - what do donuts have to do with all this? I'm about to say something that is going to blow your mind. Composable functions can be thought to be made up of donuts that are internally made up of smaller donuts 🍩. At least that's the metaphor the Compose team has been using to describe the optimizations related to recompositions. The composable function itself can be though to represent the donut, whereas its scope is the donut hole. Whenever possible, the Compose runtime skips running the "donut" if its not reading the value that changed (assuming its input didn't change either) and will only run the "donut-hole" (i.e its scope, assuming that's where the value is being read).
Let's visualize the composables in example 2 with this lens and see how they are being recomposed. Anything with the chequered pattern represents that it was recomposed.
State before any of these functions are composed
Counter = 0 First composition, all composable functions and their scopes are executed and composed
Counter = 1 Only Button Scope and CustomText, & CustomText Scope are recomposed. MyComponent, MyComponent Scope & Button are skipped from recomposition.
Counter = 2 Only Button Scope and CustomText, & CustomText Scope are recomposed. MyComponent, MyComponent Scope & Button are skipped from recomposition.
Hopefully this visualization was clear to get the point across.
Update (September 18, 2021)
Soon after I posted this article, Leland Richardson(one of the developers leading the development of Jetpack Compose) added some more context behind donut-hole skipping. Not only did he tweet with some more information, he also spent some 1-1 time with me illustrating how unique Compose was when compared to some of the other declarative systems. Let's try and understand what Leland is trying to say in the tweets below by looking at another example that builds on top of the previous two examples.
Example 3
@Composable
fun MyComponent() {
val counter by remember { mutableStateOf(0) }
LogCompositions("JetpackCompose.app", "MyComposable function")
+ val readingCounter = counter
+ CustomButton(onClick = { counter++ }) {
LogCompositions("JetpackCompose.app", "CustomButton scope")
CustomText(
text = "Counter: $counter",
modifier = Modifier
.clickable {
counter++
},
)
}
}
We make some minor modifications to the previous example. First, we replaced the use of Button
with a new component called CustomButton
. Second, we read the value of counter in the top-level
scope of MyComponent
function. Let's also take a look at the implementation of the CustomButton
function.
@Composable
fun CustomButton(
onClick: () -> Unit,
content: @Composable () -> Unit
) {
LogCompositions("JetpackCompose.app", "CustomButton function")
Button(onClick = onClick, modifier = Modifier.padding(16.dp)) {
LogCompositions("JetpackCompose.app", "Button function")
content()
}
}
CustomButton
is a pretty simple composable function that merely calls the Button
composable underneath. The reason we created this as a separate function was to add some log statement
to the function so that we could monitor them and derive insights like we did with the previous examples. Let's give this example a spin and see what happens 🤞🏻
We notice that all functions were called during the first composition, as we'd expect. However, everytime time the value of the counter changes, CustomButton
function and Button
function
are skipped altogether. We learnt from the previous examples that Compose will be smart about skipping Composable functions that aren't reading a mutable state object or if their inputs don't change.
This example reiterates the same observation. However, there's something unique about the behavior of Jetpack Compose that's worth highlighting and you probably missed it when you looked at the logs of this example.
In order to visualize this, let's look at the donut diagram for Example 3—
State before any of these functions are composed
Counter = 0 First composition, all composable functions and their scopes are executed and composed
Counter = 1 Everything except CustomButton is recomposed again
Counter = 2 Everything except CustomButton is recomposed again
What makes Compose special is the fact that its able to skip the recomposition of CustomButton while recomposing its parent (MyComponent) and its child (CustomText) 🤯 This is unique because most declarative systems identify the smallest subtree that needs to be recomposed and reinvoke the entire subtree without skipping any nodes within that subtree.
And this, folks, is why this optimization by Compose is referred to as “donut-hole skipping” as its able to skip nodes in the subtree that don't need to be recomposed.
Summary
As you could see, Jeptack Compose tries hard to avoid doing unnecessary work where possible. However, the onus is on the developers to be good citizens of the framework. You can do this by giving Compose extra information to be able to do these optimizations and following some of the rules of recomposition listed in the documentation. If you are interested in reading more about topics related to recomposition and state, my good friend Zach Klippenstein has a few blog posts that are worth checking out.
I hope I was able to teach you something new today. There's more articles in the pipeline that I'm excited to share with y'all and if you are interested in getting early access to them, consider signing up to the newsletter that's linked below. Until next time!
Subscribe for exclusive content and early access to content 👇