What are the different Jetpack Compose phases executed as part of recomposition

Issue

I’m looking at the Jetpack Compose Phases * and trying to mentally map how it applies to recomposition

Basic phases of composition

Phase State Reads

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

https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/master/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter3_layout/Tutorial3_7_1CompositionLayoutPhases.kt

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)

Leave a Reply

Your email address will not be published. Required fields are marked *