android - Why is using a LaunchedEffect with a key triggering recomposition of the whole Composable hierarchy? - Stack Overflow

I stumbled across a strange issue with using a LaunchedEffect(key) in Jetpack Compose and tracked it do

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.


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:

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"

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.


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:

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"
Share Improve this question asked Nov 16, 2024 at 12:31 BenjyTecBenjyTec 11.2k4 gold badges24 silver badges50 bronze badges 1
  • 1 You read pseudoState in scope of Surface and there happens a composition check. If in that check inputs of a function is to change it changes and that function gets recomposed. If inputs text, Modifier, color, etc do not change it gets skipped. There are 2 things for recomposition. First whether a scope should be checked for changes, especially when states are read. If recomposition check happens happen whether functions should be skipped or not based on their inputs change durning this check and previous recomposition. – Thracian Commented Nov 17, 2024 at 11:06
Add a comment  | 

3 Answers 3

Reset to default 3

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

@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)

                )
            }
        }
    }
}

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

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"
}

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.

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
        }
    }
}

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

发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745658337a4638678.html

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信