BenjyTec
BenjyTec

Reputation: 10887

Why is using a LaunchedEffect with a key triggering recomposition of the whole Composable hierarchy?

I stumbled across a strange issue with using a LaunchedEffect(key) in Jetpack Compose and tracked it down to the following minimal example:

Surface(
    modifier = Modifier.fillMaxSize().safeDrawingPadding(),
    color = MaterialTheme.colorScheme.background
) {

    var pseudoState by remember {
        mutableStateOf(false)
    }

    LaunchedEffect(Unit) {}  // NOTE: I am using Unit here

    Column {
        Button(onClick = { pseudoState = !pseudoState }) {
            Text(text = "TOGGLE pseudoState to ${!pseudoState}")
        }
        Text(text = "Random: ${Math.random()}")
    }
}

When I run it, note that the Button is correctly recomposed after clicking it, and the other Text Composable is skipped.

enter image description here


Now, I make one small adjustment by providing pseudoState as a key to the empty LaunchedEffect:

LaunchedEffect(pseudoState) {}  // NOTE: I am using pseudoState now

Now, with every single click on the Button, both the Button and the Text get recomposed:

enter image description here

Why is this happening?


I use the following dependencies:

[versions]

agp = "8.3.2"
kotlin = "1.9.0"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.11.00"

Upvotes: 3

Views: 250

Answers (3)

Pablo Valdes
Pablo Valdes

Reputation: 984

Is an optimization applied in compose. It is known as doughnut skipping. Check this article: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose

Upvotes: 0

Thracian
Thracian

Reputation: 67268

When a state is read in a scope, non-inline Composable function that returns Unit and not annotated with @NonRestartableComposable, that scope is subject to recomposition check, if there any state reads in that scope or child scopes below it.

When recomposition check is done due the read in state or below if a composable is skippable no inputs has changed it gets skipped, otherwise it recomposes. Numbers shown in layout inspector show these. But when there not any numbers or no change in recomposition/skipped numbers it means there is no need further check for composition down the composition tree.

You can check my question/answer where reading a state causes everything to be recomposed, i used random color but random acts the same way which creates different output when that scope is run.

Easiest way to check if a scope is eligible for recomposition is layout inspector if it doesn't show and composition or skipped number there is state read or input change in that scope as in first example, if there are any then you see numbers change.

Check this example

@Preview
@Composable
fun MyComposable() {
    var counter by remember {
        mutableIntStateOf(0)
    }

    Column(
        modifier = Modifier.fillMaxSize()
    ) {

        Button(
            onClick = {
                counter++
            }
        ) {
            Text(
                text = "Counter: $counter",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
        }

        MyCustomScope {
            Text(
                text = "Some text",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)

            )
        }

        MyCustomScope {
            Text(
                text = "Counter $counter",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )

            MyCustomScope {
                Text(
                    text = "Inner scope text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)

                )
            }
        }
    }
}

When you increase counter

enter image description here

@Composable
fun MyCustomScope(content: @Composable () -> Unit) {
    content()
}

As you can see in the picture, top MyCustomScope does not have a check for recomposition, no numbers displayed, because there is no state read neither in nor its parent scope.

However if you check second one below where counter is read in MyComposable scope, even just adding a state is a read like LaunchEffect reading key, it creates recomposition check for that scope. Button scope and 2 outer MyCustomScope skips recomposition. If you add a Text with random color modifier inside Column you can observe that it will recompose when counter is read.

@Preview
@Composable
fun MyComposable() {
    var counter by remember {
        mutableIntStateOf(0)
    }

    Column(
        modifier = Modifier.fillMaxSize()
    ) {

        counter

        Button(
            onClick = {
                counter++
            }
        ) {
            Text(
                text = "Counter: $counter",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
        }

        MyCustomScope {
            Text(
                text = "Some text",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)

            )
        }

        MyCustomScope {
            Text(
                text = "Inner scope first text",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )

            MyCustomScope {
                Text(
                    text = "Inner scope text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)

                )
            }
        }
    }
}

enter image description here

And if you create another state does doesn't change but that is read in deepest scope like below things get even more interesting.

@Preview
@Composable
fun MyComposable() {
    var counter by remember {
        mutableIntStateOf(0)
    }

    var counter2 by remember {
        mutableIntStateOf(0)
    }

    Column(
        modifier = Modifier.fillMaxSize()
    ) {


        counter

        Button(
            onClick = {
                counter++
            }
        ) {
            Text(
                text = "Counter: $counter",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
        }

        Button(
            onClick = {
                counter2++
            }
        ) {
            Text(
                text = "Counter: $counter2",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
        }

        MyCustomScope {
            Text(
                text = "Some text",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)

            )
        }

        MyCustomScope {
            Text(
                text = "Inner scope first text",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )

            MyCustomScope {
                Text(
                    text = "counter2: $counter2",
                    modifier = Modifier.fillMaxWidth()
                        .border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
        }
    }
}

enter image description here

Since counter2 is read in inner scope, Compose checks if inputs of functions have chanhed, since there is a random color modifier that returns new Modifier, it recomposes. If you remove this modifier it skips recomposition.

Another example is

@Preview
@Composable
fun AnotherCompositionTest() {

    val viewModel = remember { TestViewModel() }

    var counter by remember {
        mutableIntStateOf(0)
    }

    Column(
        modifier = Modifier.fillMaxSize()
    ) {
        counter

        Text(
            text = "Some text",
        )
        Text(
            text = "Some text with Modifier",
            modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
        )
        Text("ViewModel : ${viewModel.someText}")

        Button(
            onClick = {
                counter++
            }
        ) {
            Text(
                text = "Counter: $counter",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
        }

        Button(
            onClick = {
                viewModel.someText = UUID.randomUUID().toString()
            }
        ) {
            Text(
                text = "Change ViewModel Text",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
        }
    }
}


class TestViewModel : ViewModel() {
    var someText = "Hello"
}

enter image description here

When you increase counter see that Text with Modifier recomposes while other 2 skips. Then click and change ViewModel text you won't see any recomposition. Then if you increase counter you will see that Text that writes Text("ViewModel : ${viewModel.someText}") recompose because its input changed in this recomposition.

Upvotes: 3

Jan Itor
Jan Itor

Reputation: 4276

While a function like Math.random() can not trigger a recomposition, it can cause a recomposition if:

  • it changes inputs of a composable
  • that composable is in a scope that is being recomposed

As docs say:

Every composable function and lambda might recompose by itself.

That means when you change pseudoState value with LaunchedEffect(Unit) there is no reason for Compose to touch anything except Button function scope because that's where sole pseudoState consumer is. If you add a random Text there, it will be recomposed:

var pseudoState by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {}
Column {
    Button(onClick = {
        pseudoState = !pseudoState
    }) {
        Column {
            Text(text = "TOGGLE pseudoState to ${!pseudoState}")
            Text(text = "Random:: ${Math.random()}")    // recomposed on click
        }
    }

Text is recomposed because when Compose evaluates its inputs, Math.random() is called and text changes. For example, if we add a function like this:

private fun getSameNumber(): Int {
    println("getSameNumber ${System.currentTimeMillis()}")
    return 7
}

And replace Math.random() with getSameNumber(), we will see that getSameNumber is called on each click, but text doesn't change and Button is not recomposed.


Essentially the same is happening when you change to LaunchedEffect(pseudoState), but in the scope of the Surface. Now it is LaunchedEffect that triggers recomposition of the scope it's in. Column is an inline function and doesn't create a scope. You can create a nested scope with some other composable, for example another Button:

Surface(
    modifier = Modifier.fillMaxSize().safeDrawingPadding(),
    color = MaterialTheme.colorScheme.background
) {
    var pseudoState by remember { mutableStateOf(false) }
    LaunchedEffect(pseudoState) {}
    Column {
        Button(onClick = {
            pseudoState = !pseudoState
        }) {
            Text(text = "TOGGLE pseudoState to ${!pseudoState}")
        }
        Text(text = "Random: ${Math.random()}")
        Text(text = "Same: ${getSameNumber()}")     // Same input, doesn't recompose
        Button(onClick = {}) {
            Text(text = "Random: ${Math.random()}")     // Another scope, doesn't recompose
        }
    }
}

Upvotes: 1

Related Questions