I'm doing experiments to comprehend recomposition and smart recomposition and made a sample
Sorry for the colors, they are generated with Random.nextIn() to observe recomposition visually, setting colors has no effect on recomposition, tried without changing colors either.
What's in gif is composed of three parts
Sample1
@Composable
private fun Sample1() {
Column(
modifier = Modifier
.background(getRandomColor())
.fillMaxWidth()
.padding(4.dp)
) {
var counter by remember { mutableStateOf(0) }
Text("Sample1", color = getRandomColor())
Button(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
counter++
}) {
Text("Counter: $counter", color = getRandomColor())
}
}
}
I have no questions here since smart composition works as expected, Text
on top is not reading changes in counter
so recomposition only occurs for Text
inside Button
.
Sample2
@Composable
private fun Sample2() {
Column(
modifier = Modifier.background(getRandomColor())
) {
var update1 by remember { mutableStateOf(0) }
var update2 by remember { mutableStateOf(0) }
println("ROOT")
Text("Sample2", color = getRandomColor())
Button(
modifier = Modifier
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
update1++
},
shape = RoundedCornerShape(5.dp)
) {
println("๐ฅ Button1๏ธ")
Text(
text = "Update1: $update1",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
Button(
modifier = Modifier
.padding(start = 8.dp, end = 8.dp, top = 2.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update2++ },
shape = RoundedCornerShape(5.dp)
) {
println("๐ Button 2๏ธ")
Text(
text = "Update2: $update2",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
Column(
modifier = Modifier.background(getRandomColor())
) {
println("๐ Inner Column")
var update3 by remember { mutableStateOf(0) }
Button(
modifier = Modifier
.padding(start = 8.dp, end = 8.dp, top = 2.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update3++ },
shape = RoundedCornerShape(5.dp)
) {
println("โ
Button 3๏ธ")
Text(
text = "Update2: $update2, Update3: $update3",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
Column() {
println("โ๏ธ Bottom Column")
Text(
text = "Sample2",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
}
It also works as expected each mutableState is updating only the scope they have been observed in. Only Text
that observes update2
and update3
is changed when either of these mutableStates are updated.
Sample3
@Composable
private fun Sample3() {
Column(
modifier = Modifier.background(getRandomColor())
) {
var update1 by remember { mutableStateOf(0) }
var update2 by remember { mutableStateOf(0) }
println("ROOT")
Text("Sample3", color = getRandomColor())
Button(
modifier = Modifier
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = {
update1++
},
shape = RoundedCornerShape(5.dp)
) {
println("๐ฅ Button1๏ธ")
Text(
text = "Update1: $update1",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
Button(
modifier = Modifier
.padding(start = 8.dp, end = 8.dp, top = 2.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update2++ },
shape = RoundedCornerShape(5.dp)
) {
println("๐ Button 2๏ธ")
Text(
text = "Update2: $update2",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
Column {
println("๐ Inner Column")
var update3 by remember { mutableStateOf(0) }
Button(
modifier = Modifier
.padding(start = 8.dp, end = 8.dp, top = 2.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = getRandomColor()),
onClick = { update3++ },
shape = RoundedCornerShape(5.dp)
) {
println("โ
Button 3๏ธ")
Text(
text = "Update2: $update2, Update3: $update3",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
// ๐ฅ๐ฅ Reading update1 causes entire composable to recompose
Column(
modifier = Modifier.background(getRandomColor())
) {
println("โ๏ธ Bottom Column")
Text(
text = "Update1: $update1",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
}
Only difference between Sample2 and Sample3 is Text
at the bottom is reading update1 mutableState which causing entire composable to be recomposed. As you can see in gif changing update1
recomposes or changes entire color schema for Sample3.
What's the reason for recomposing entire composable?
Column(
modifier = Modifier.background(getRandomColor())
) {
println("โ๏ธ Bottom Column")
Text(
text = "Update1: $update1",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
To have smart recomposition scopes play a pivotal role. You can check Vinay Gaba's What is โdonut-hole skippingโ in Jetpack Compose? article.
Leland Richardson explains in this tweet as
The part that is "donut hole skipping" is the fact that a new lambda being passed into a composable (ie Button) can recompose without recompiling the rest of it. The fact that the lambda are recompose scopes are necessary for you to be able to do this, but not sufficient
In other words, composable lambda are "special" :)
We wanted to do this for a long time but thought it was too complicated until @chuckjaz had the brilliant realization that if the lambdas were state objects, and invokes were reads, then this is exactly the result
You can also check other answers about smart recomposition here, and here.
https://dev.to/zachklipp/scoped-recomposition-jetpack-compose-what-happens-when-state-changes-l78
When a State is read it triggers recomposition in nearest scope. And a scope is a function that is not marked with inline and returns Unit. Column, Row and Box are inline functions and because of that they don't create scopes.
Created RandomColorColumn
that take other Composables
and its scope content: @Composable () -> Unit
@Composable
fun RandomColorColumn(content: @Composable () -> Unit) {
Column(
modifier = Modifier
.padding(4.dp)
.shadow(1.dp, shape = CutCornerShape(topEnd = 8.dp))
.background(getRandomColor())
.padding(4.dp)
) {
content()
}
}
And replaced
Column(
modifier = Modifier.background(getRandomColor())
) {
println("โ๏ธ Bottom Column")
Text(
text = "Update1: $update1",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
with
RandomColorColumn() {
println("โ๏ธ Bottom Column")
/*
๐ฅ๐ฅ Observing update(mutableState) does NOT causes entire composable to recompose
*/
Text(
text = "๐ฅ Update1: $update1",
textAlign = TextAlign.Center,
color = getRandomColor()
)
}
}
Only this scope gets updated as expected and we have smart recomposition.
What causes Text
, or any Composable, inside Column
to not have a scope, thus being recomposed when a mutableState value changes is Column
having inline keyword in function signature.
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
Layout(
content = { ColumnScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
If you add inline to RandomColorColumn
function signature you will see that it causes whole Composable to recompose.
Compose uses call sites defined as
The call site is the source code location in which a composable is called. This influences its place in Composition, and therefore, the UI tree.
If during a recomposition a composable calls different composables than it did during the previous composition, Compose will identify which composables were called or not called and for the composables that were called in both compositions, Compose will avoid recomposing them if their inputs haven't changed.
Consider the following example:
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
Call site of a Composable function affects smart recomposition, and having inline keyword in a Composable sets its child Composables call site same level, not one level below.
For anyone interested here is the github repo to play/test recomposition