What is “donut-hole skipping” in Jetpack Compose?

Recomposition

Example 1

@Composable
fun MyComponent() {
val counter by remember { mutable state of(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
)
)
}
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}")
}
}
@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
)
)
}

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++
- },
)
+ }
}

Recomposition Scope

Example 1 with its recomposition scopes
  • 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.
Example 2 with its recomposition scopes
  • 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 the counter is read and passed to the CustomText composable as an input

What does a donut have to do with all this?

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 the recomposition.
Counter = 2 Only Button Scope and CustomText, & CustomText Scope are recomposed. MyComponent, MyComponent Scope & Button are skipped from the recomposition.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store