Issue
I’m looking at the Jetpack Compose Phases * and trying to mentally map how it applies to recomposition
Assuming i have my viewmodel like this:
data class MyDataClass(
val contentA: String? = null,
val contentB: String? = null,
val contentC: String? = null,
val contentD: String? = null,
val editTextContent: String? = null // EDIT-1
)
class MyViewModel: ViewModel() {
private val _uiState = MutableStateFlow(MyDataClass())
val uiState = _uiState.asStateFlow
fun updateContentA(newContent: String) {
_uiState.update { it.copy(contentA = newContent) }
}
// EDIT-1
fun updateEditText(newContent: String) {
_uiState.update { it.copy(editTextContent = newContent) }
}
}
My Jetpack compose UI layout looks like this:
fun MyMainScreen(viewModel = MyViewModel()) {
// P1: Because I'm collecting the latest state here, everytime the uiState updates
// this compose function (MyMainScreen) gets its `recomposed` count incremented on
// layout inspector
val uiState by viewModel.uiState.collectAsStateWithLifecycle().value
// P2: Though only one of the text changed, all these method's recompose count
// gets incremented but the leaf node gets skipped from recomposition.
MyMiniUiElement(uiState.contentA)
MyMidUiElement(uiState.contentB)
MyLargeUiElement(uiState.contentC)
MyHugeUiElement(uiState.contentD)
MyEditTextField(uiState.editTextContent) // EDIT-1
}
fun MyMiniUiElement(val text: String) {
Text()
}
fun MyMidUiElement(val text: String) {
Text()
}
fun MyLargeUiElement(val text: String) {
Text()
}
fun MyHugeUiElement(val text: String) {
Text()
}
//EDIT-1
fun MyEditTextField(val text: String, onValueChange: (String) -> Unit) {
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(text = text))
}
BasicTextField(
value = text,
onValueChange = {
textFieldValue = TextFieldValue(it, TextRange(Int.MAX_VALUE))
onValueChange(it)
}
)
}
With my 2 points above (P1
and P2
), when the functions MyMainScreen
and My{x}UiElement
get recomposed, which phases are getting executed and what are the implications? Does it put extra load on the CPU or is it computing any layout measurements or redrawing pixels?
Note: I understand the best practice** is to avoid passing the viewmodel down to the other composables. But, if i were to pass the viewModel down, technically i could avoid MyMainScreen
and My{x}UiElement
from recomposing.
* https://developer.android.com/jetpack/compose/phases
** https://developer.android.com/topic/libraries/architecture/viewmodel#best-practices
Solution
Scoped Recomposition
When a variable reads inside a scope that every Composable in that scope is eligible for recomposition. Scope is a non-inline Composable function that returns Unit. Which renders Column
, Box
or Row
ineligible because both are inline functions.
For instance,
fun getRandomColor() = Color(
red = Random.nextInt(256),
green = Random.nextInt(256),
blue = Random.nextInt(256),
alpha = 255
)
@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()
)
}
}
}
In example above reading update1 causes Sample3
scope or any Composable inside Column eligible to be recomposed. You can refer this answer for more details about scoped recomposition.
Jetpack Compose Scoped/Smart Recomposition
And when recomposition happens or not draw
phase of every Composable is called as far as i recall. Check out my question and linked other questions observing draw phases of unrelated Composables being called but scoping also works for this one too. Jetpack Compose deferring reads in phases for performance
When recomposition happens layout
phase might or might not be skipped based on any dimension of a Composable needs to be changes.
You can observe layout phase with Modifier.layout{}
, draw
phase by assigning any draw Modifier such as Modifier.drawWithContent/drawBehind or drawWithCache.
@Preview
@Composable
fun SomeComposable() {
var counter by remember {
mutableStateOf(0)
}
Column {
Text("Counter $counter", modifier = Modifier
.border(2.dp, getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("First Text PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("First text draw Scope")
drawContent()
}
)
Text("Fixed Text", modifier = Modifier
.border(2.dp, getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("Second Text PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("Second text draw Scope")
drawContent()
}
)
MyText("MyText")
MyText2("MyText2", modifier = Modifier
.border(2.dp, getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("MyText2 PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("MyText2 draw Scope")
drawContent()
}
)
MyBox()
MyBox2(
modifier = Modifier.size(100.dp).background(getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("MyBox2 PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("MyBox2 draw Scope")
drawContent()
}
)
Button(
onClick = {
counter++
}
) {
Text("Counter: $counter")
}
}
}
@Composable
fun MyText(text: String) {
Column {
Text(text, modifier = Modifier
.border(2.dp, getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("MyText PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("MyText draw Scope")
drawContent()
}
)
}
}
@Composable
fun MyText2(text: String, modifier: Modifier) {
Column {
Text(
text, modifier = modifier
)
}
}
@Composable
fun MyBox() {
Box(
modifier = Modifier.size(100.dp).background(getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("MyBox PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("MyBox draw Scope")
drawContent()
}
)
}
@Composable
fun MyBox2(modifier: Modifier) {
Box(
modifier = modifier
)
}
When counter is increased it will log something like on recomposition
I First Text PlacementScope
I Second Text PlacementScope
I MyText2 PlacementScope
I First text draw Scope
I Second text draw Scope
I MyText draw Scope
I MyText2 draw Scope
I MyBox draw Scope
I MyBox2 draw Scope
Which means Text
composable calls layout phase on each recomposition. MyText
does not get recomposed but MyText2
gets recomposed because we update Modifier with random color which is the input that changes.
However, with Box
since there is no need to update layout phase since their size don’t change, this part depends how you set Modifier.layout or when creating a custom composable how you set MeasurePolicy
.
Layout Phase
As stated above layout phase is called only when it’s required when it’s not necessary it’s skipped.
@Composable
private fun MyCustomLayout(
counter: Int,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
SideEffect {
println("MyCustomLayout recomposing...")
}
Column {
Text("Counter $counter")
Layout(
modifier = modifier,
content = content
) { measurable, constraints ->
val placeables = measurable.map {
it.measure(constraints)
}
val width =
if (constraints.hasBoundedWidth && constraints.hasFixedWidth) constraints.maxWidth
else placeables.maxOf { it.width }
val height = placeables.sumOf { it.height }
var yPos = 0
layout(width, height) {
println("MyCustomLayout placement scope")
placeables.forEach {
it.placeRelative(0, yPos)
yPos = it.height
}
}
}
}
}
If you put a BasicTextField inside this Composable as
@Preview
@Composable
fun MyTextFieldPreview() {
var text by remember {
mutableStateOf("hello")
}
var counter by remember {
mutableStateOf(0)
}
Column {
MyCustomLayout(
counter = counter,
modifier = Modifier
.border(2.dp, getRandomColor())
.drawWithContent {
drawContent()
println("MyCustomLayout draw phase")
}
) {
BasicTextField(
text,
onValueChange = { text = it },
modifier = Modifier.background(Color.Yellow)
)
}
Button(
onClick = {
text = "hello"
}
) {
Text("Update to hello")
}
Button(
onClick = {
text = "olleh"
}
) {
Text("Update to olleh")
}
Button(
onClick = {
counter++
}
) {
Text("Increase Counter")
}
}
}
On each counter change we trigger recomposition MyCustomLayout
and prints
I MyCustomLayout recomposing...
I MyCustomLayout draw phase
because we update input of MyCustomLayout
but there is no need to call Layout neither width nor height changes by any outer modifier or child Composables require measurement and layout of their own.
If we click top 2 buttons we will see that nothing happens on MyCustomLayout
since its inputs do not change not width of BasicTextField. However if we keep typing till total width of input text is above default width of BasicTextField it will print
I MyCustomLayout placement scope
I MyCustomLayout draw phase
As it shows layout phase of a Composable is called only when required.
Also updated your sample as
data class MyDataClass(
val contentA: String = "text",
val contentB: String = "text",
val contentC: String = "text",
val contentD: String = "text",
val editTextContent: String = "Some text"
)
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MyDataClass())
val uiState = _uiState.asStateFlow()
fun updateContentA(newContent: String) {
_uiState.update { it.copy(contentA = newContent) }
}
// EDIT-1
fun updateEditText(newContent: String) {
_uiState.update { it.copy(editTextContent = newContent) }
}
}
@Preview
@Composable
fun CompositionTest() {
val viewModel = remember {
MyViewModel()
}
MyMainScreen(viewModel)
}
@Composable
fun MyMainScreen(viewModel: MyViewModel) {
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
Column {
MyMiniUiElement(uiState.contentA)
MyMidUiElement(uiState.contentB)
MyLargeUiElement(uiState.contentC)
MyHugeUiElement(uiState.contentD)
MyEditTextField(uiState.editTextContent) {
viewModel.updateEditText(it)
}
SideEffect {
println("Composing state: $uiState")
}
Button(
onClick = {
viewModel.updateContentA(uiState.editTextContent)
}
) {
Text("Update")
}
}
}
@Composable
fun MyMiniUiElement(text: String) {
SideEffect {
println("MyMiniUiElement recomposing $text")
}
Text(text, modifier = Modifier
.border(2.dp, getRandomColor())
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(constraints.maxWidth, placeable.height) {
println("MyMiniUiElement PlacementScope")
placeable.placeRelative(0, 0)
}
}
.drawWithContent {
println("MyMiniUiElement draw Scope")
drawContent()
}
)
}
@Composable
fun MyMidUiElement(text: String) {
SideEffect {
println("MyMidUiElement recomposing $text")
}
Text(text, modifier = Modifier.border(2.dp, getRandomColor()))
}
@Composable
fun MyLargeUiElement(text: String) {
Text(
text, modifier = Modifier
.border(2.dp, getRandomColor())
)
}
@Composable
fun MyHugeUiElement(text: String) {
Text(text, modifier = Modifier.border(2.dp, getRandomColor()))
}
@Composable
fun MyEditTextField(text: String, onValueChange: (String) -> Unit) {
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(text = text))
}
BasicTextField(
modifier = Modifier.border(2.dp, getRandomColor()).padding(8.dp),
value = text,
onValueChange = {
textFieldValue = TextFieldValue(it, TextRange(Int.MAX_VALUE))
onValueChange(it)
}
)
}
Where you can observe effects of scoping and how phases are called as exactly as examples above.
Also on composition composition, layout phases are done in depth first traversal tree
For a Layout like in this tutorial the order of phase calls are like
/*
Parent Layout
/ \
/ \
/ \
/ \
Child1 Outer Child2
|
Child1 Inner
Prints:
I Parent Scope
I Child1 Outer Scope
I Child1 Inner Scope
I Child2 Scope
I 🍏 Child1 Inner MeasureScope minHeight: 0, maxHeight: 275, minWidth: 0, maxWidth: 1080
I contentHeight: 52, layoutHeight: 52
I 🍏 Child1 Outer MeasureScope minHeight: 275, maxHeight: 275, minWidth: 0, maxWidth: 1080
I contentHeight: 104, layoutHeight: 275
I 🍏 Child2 MeasureScope minHeight: 0, maxHeight: 2063, minWidth: 0, maxWidth: 1080
I contentHeight: 52, layoutHeight: 52
I 🍏 Parent MeasureScope minHeight: 0, maxHeight: 2063, minWidth: 1080, maxWidth: 1080
I contentHeight: 327, layoutHeight: 327
I 🍏🍏 Parent Placement Scope
I 🍏🍏 Child1 Outer Placement Scope
I 🍏🍏 Child1 Inner Placement Scope
I 🍏🍏 Child2 Placement Scope
*/
Answered By – Thracian
Answer Checked By – Candace Johnson (FlutterFixes Volunteer)