androidkotlinandroid-jetpack-compose

Jetpack Compose State Capture in long-lived lambda


In the Test Composable, action is a variable of type mutableStateOf, initialized with ::a. Then, in the LaunchedEffect, action is changed to ::b.

However, because LaunchedEffect(Unit) is by default bound to the Unit key in Compose, it means that it triggers when the Composable is first executed, and during this process, it captures a reference to perform the specified action. As a result, changes to action do not immediately reflect in ExecuteInLongLambda because action was captured by Compose during the initial suspension.

After the delay in LaunchedEffect completes, even though action has been updated to ::b, ExecuteInLongLambda still executes the initially captured action (i.e., ::a) from the first render, so ::a is executed in the end. I know this should be solved using rememberUpdatedState(), My doubt is that I created a lambda which capture State (i.e counter) and pass the lambda as action2 to ExecuteInLongLiveLambda. When counter changes, the lambda that captures counter should be recreated. Why does it behave differently from action1? Shouldn't the printed counter be 0? Can someone explain in detail what is happening?

fun a() {
  Log.e("MainActivity", "a")
}

fun b() {
  Log.e("MainActivity", "b")
}

@Composable
fun Test() {
  var action by remember { mutableStateOf(::a) }
  var counter by remember { mutableIntStateOf(0) }

  LaunchedEffect(Unit) {
    delay(1000)
    action = ::b
    counter++
  }

  ExecuteInLongLiveLambda(
    action1 = action,
    action2 = {
      // read State<Int>
      Log.e("MainActivity", "counter = $counter")
    }
  )
}

@Composable
fun ExecuteInLongLiveLambda(
  action1: () -> Unit,
  action2: () -> Unit,
) {
  LaunchedEffect(Unit) {
    delay(2000)
    action1() // a
    action2() // counter = 1
  }
}

Solution

  • TL;DR:

    Due to the delegation you only capture the underlying MutableState, not the Int. And this always stays the same object, only its content is changed. When you actually access the content (after two seconds), the value will be 1, not 0.


    Detailed answer, this is what happens:

    First composition

    1. action is set to ::a and counter is set to 0.
    2. LaunchedEffect is started, which executs with a second delay.
    3. ExecuteInLongLiveLambda is called, with these parameters:
      • action1: ::a
      • action2: Captures counter which is a MutableState with value 0
    4. ExecuteInLongLiveLambda starts a LaunchedEffect that captures the parameters from 3. and executes the lambda. The first thing it does is waiting for 2 seconds, so nothing more happens right now.

    So far so good. Now, after a second the LaunchedEffect from 2. sets action to ::b and counter to 1. Since both are backed by a MutableState that is accessed by Test, a recomposition is triggered.

    Second composition

    1. Since action and counter are remembered they keep their current value (i.e. ::b and 1).
    2. The LaunchedEffect is skipped because the key (the first parameter, Unit) didn't change.
    3. ExecuteInLongLiveLambda is called again, with these parameters:
      • action1: ::b
      • action2: Captures counter which is a MutableState with value 1
    4. The LaunchedEffect in ExecuteInLongLiveLambda is skipped because the key didn't change.

    This LaunchedEffect (the one from ExecuteInLongLiveLambda) never sees the two updated parameters. The coroutine that was internally launched on first composition is still running (still waiting for two seconds where only one has passed by now).

    After another second passes the two seconds this LaunchedEffect was waiting are over and the rest from the lambda is executed. Keep in mind, this is the lambda with the parameters 3. from the first composition.

    1. action1() is executed. Since that was ::a when the LaunchedEffect was created, that is executed which logs a.
    2. action2() is executed. It didn't change during recomposition, so even when the LaunchedEffect hadn't captured it it would behave the same. The counter may have changed, but the lambda is still the same.
      As seen in 3. (both versions), counter was captured. And this is where it gets interesting: Due to the by delegation this looks like an Int, but it actually is a MutableState. Under the hood the delegation calls getValue() and setValue() respectively everytime you access it. This means, what was really captured was the MutableState and that did never change. Only its value changed. But the first time that is accessed is when the lambda actually is executed. And the then current value is retrieved. At this point of time it is 1, not 0.

    Alternatives

    This behavior changes if you actually capture the Int. Let's assume you have an additional variable like this in Test:

    val counterValue = counter
    

    When you access this counterValue in the action2 lambda, then you will actually capture the Int 0 (not a MutableState with value 0), and that is also what will be logged later on.

    Now, you might say that action is also a delegate, like counter, so that should behave the same way. That's not the case, though, because you never access action in a lambda. You just pass it as a parameter to ExecuteInLongLiveLambda, and only that parameter is then captured in the ExecuteInLongLiveLambda's LaunchedEffect. ExecuteInLongLiveLambda never sees a MutableState, only ::a and ::b directly.