返回

解密Compose LaunchedEffect诡异的Lambda状态捕获

Android

解密 Jetpack Compose:为何长寿 Lambda 中的状态捕获如此“诡异”?

写 Compose UI 时,状态管理和副作用处理是家常便饭。LaunchedEffect 是处理副作用的常用工具,但有时它配合 Lambda 和 State 使用时,行为会让人有点摸不着头脑。

今天咱们就来聊聊一个常见的场景:在一个启动后就不再轻易重启的 LaunchedEffect 里,一个外部传入的 Lambda 参数似乎“锁死”了初始状态,而另一个直接读取 State 的 Lambda 却能拿到最新值。这到底是怎么回事?

问题:长寿 Lambda 里的状态怪象

先来看一段代码,它直观地展示了这个问题:

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import kotlinx.coroutines.delay

// 两个简单的日志打印函数
fun a() {
  Log.e("MainActivity", "执行 a()")
}

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

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Test()
        }
    }
}

@Composable
fun Test() {
  // action 的状态,初始指向函数 a
  var action by remember { mutableStateOf(::a) }
  // counter 的状态,初始为 0
  var counter by remember { mutableIntStateOf(0) }

  // 这个 LaunchedEffect 使用 Unit作为 key,只在 Test首次组合时启动一次
  LaunchedEffect(Unit) {
    // 1秒后,更新 action 指向函数 b,并增加 counter
    delay(1000)
    Log.d("MainActivity", "更新状态:action -> b, counter -> 1")
    action = ::b
    counter++
  }

  Log.d("MainActivity", "Test Composable 重组或首次组合")

  // 将 action 和一个读取 counter 的 Lambda 传给另一个 Composable
  ExecuteInLongLiveLambda(
    action1 = action,
    action2 = {
      // 这个 Lambda 直接读取 counter 状态
      Log.e("MainActivity", "action2 执行,当前 counter = $counter")
    }
  )
}

@Composable
fun ExecuteInLongLiveLambda(
  action1: () -> Unit,
  action2: () -> Unit,
) {
  Log.d("MainActivity", "ExecuteInLongLiveLambda Composable 重组或首次组合")

  // 这个 LaunchedEffect 也使用 Unit 作为 key,同样只在首次组合时启动
  LaunchedEffect(Unit) {
    Log.d("MainActivity", "ExecuteInLongLiveLambda 的 LaunchedEffect 启动")
    // 2秒后执行传入的 action1 和 action2
    delay(2000)
    Log.d("MainActivity", "准备执行 action1 和 action2")
    action1() // 问题点1:这里执行的是 a() 还是 b()?
    action2() // 问题点2:这里打印的 counter 是 0 还是 1?
  }
}

如果你运行这段代码,观察 Logcat 输出,会发现:

  1. Test Composable 首次组合,两个 LaunchedEffect 先后启动。
  2. 约 1 秒后,Test 中的 LaunchedEffect 更新 action::bcounter 变为 1。这次状态更新会触发 Test Composable 的重组ExecuteInLongLiveLambda 也会跟着重组,因为它接收了 action 作为参数,并且 action 的引用变了(从 ::a 变为 ::b)。
  3. 又过了 1 秒(总共 2 秒后),ExecuteInLongLiveLambda 中的 LaunchedEffect 开始执行 action1()action2()
  4. 出乎意料的输出:
    • action1() 执行的结果是打印 "执行 a()"。
    • action2() 执行的结果是打印 "action2 执行,当前 counter = 1"。

这就奇怪了!action 明明在 1 秒后就被更新成了 ::b,为什么 2 秒后执行 action1 时还是 ::a?而 counter 明明也是在 1 秒后才变成 1,为什么 action2 就能读到最新的值 1,而不是初始值 0?

为什么 action1 执行的是旧逻辑?Lambda 捕获的“陷阱”

问题的关键在于 LaunchedEffect(Unit) 的行为和 Lambda 的捕获机制

  1. LaunchedEffect(Unit) 的生命周期 :当 key1 参数是 Unit(或其他常量)时,LaunchedEffect 的协程只会在 Composable 首次进入组合 (Composition) 时启动。之后,即使 Composable 因为状态变化而重组 (Recomposition),只要 key1 没变,这个协程就不会重启,会一直运行下去,直到 Composable 离开组合。

  2. Lambda 参数的捕获 :当 ExecuteInLongLiveLambda 首次组合 时,它内部的 LaunchedEffect(Unit) 启动了。在这个启动时刻,它接收到的 action1 参数是 Test Composable 当时 action 状态的值,也就是函数引用 ::aLaunchedEffect 的协程“捕获”了这个特定的函数引用 ::a 作为它要执行的 action1

  3. 状态更新与协程 :随后,Test 中的 LaunchedEffect 在 1 秒后执行了 action = ::b。这个操作更新了 Testaction 状态。状态更新触发了 Test 的重组,连带着 ExecuteInLongLiveLambda 也进行了重组。在这次重组中,传递给 ExecuteInLongLiveLambdaaction1 参数 确实 是新的函数引用 ::b
    但是ExecuteInLongLiveLambda 内部的 LaunchedEffect(Unit) 并不会因为重组而重启 !它仍然是最初启动的那个协程实例,它在启动时捕获的 action1 仍然是那个旧的 ::a 引用。

所以,等到 2 秒后协程执行 action1() 时,它执行的是启动时捕获的那个旧值:::a

简单来说,对于 action1
LaunchedEffect(Unit) 在启动时捕获了 action1 参数的当时值 (也就是 ::a 的引用)。之后即使 action 状态变量变化,导致重组时传入了新的 action1 参数 (::b),已经运行的协程内部使用的仍然是最初捕获 的那个值。

action2 为何能读到新状态?解开 lambda 闭包与 Compose State 的谜团

那为什么 action2 的行为又不一样呢?它怎么就能读到更新后的 counter 值 1 呢?

这涉及到 Lambda 闭包和 Compose State 对象的工作方式:

  1. Lambda 闭包 (Closure) :当你创建一个 Lambda 表达式,比如 action2 = { Log.e("MainActivity", "counter = $counter") },这个 Lambda 会“闭包”它引用的外部变量。重要的是,它通常捕获的是变量本身(或者说对变量的引用) ,而不是变量在创建 Lambda 时的瞬时值。

  2. Compose State 的本质remember { mutableStateOf(...) } 返回的不是一个普通的 Kotlin 变量,而是一个 State<T> 类型的对象(比如 MutableState<Int>)。你可以把它想象成一个“盒子”,里面装着实际的值。counter 这个变量,实际上持有的是对这个 State<Int> 盒子的引用。

  3. action2 的捕获action2 这个 Lambda 表达式在创建时,闭包捕获了 counter 变量。这意味着 action2 内部持有了对那个 State<Int> 盒子的引用。

  4. 读取最新值 :当 ExecuteInLongLiveLambdaLaunchedEffect 在 2 秒后执行 action2() 时,Lambda 内部的代码 Log.e("...", "... counter = $counter") 被执行。这时,它通过捕获到的 counter 变量(也就是对 State<Int> 盒子的引用),访问这个盒子的当前值 (.value)。由于 Test 中的 LaunchedEffect 已经在 1 秒时执行了 counter++,修改了那个盒子里的值,所以 action2 读取到的是更新后的值 1

对比一下 action1action2 的捕获内容:

  • action1 被捕获时,捕获的是 action 变量的 ,这个值是函数引用 ::a 。这是一个不可变的值。之后 action 变量本身被修改为指向 ::b,但协程里捕获的那个旧值 ::a 不会变。
  • action2 捕获的是 counter 变量 ,这个变量指向一个 State<Int> 对象(盒子)。之后 counter++ 修改的是盒子里的内容 ,而不是改变 counter 指向哪个盒子。action2 执行时,通过它捕获的盒子引用,读取了盒子当时 的内容。

如何确保 Lambda 总是执行最新逻辑?

知道了原因,解决起来就有方向了。我们希望即使 LaunchedEffect 不重启,它执行的 Lambda 也能反映最新的状态或逻辑。

方案一:rememberUpdatedState - 正确的“状态快照”姿势

Compose 提供了 rememberUpdatedState 这个 API,就是专门用来解决这个问题的。它可以创建一个 State 对象,这个对象的值总会被更新为最近一次重组时 传入的值,但读取这个 State 对象不会 触发读取点的重组,并且这个 State 对象本身的引用是稳定的,不会导致依赖它的 LaunchedEffect 重启。

原理和作用:

rememberUpdatedState 返回一个 State 对象,其 .value 属性在每次重组时都会被更新为最新的传入值。当你把这个 State 对象传递给一个长寿的协程(如 LaunchedEffect(Unit))时,协程虽然只在启动时捕获了这个 State 对象的引用(这个引用是稳定的),但在协程内部实际执行逻辑、需要读取值的时候,通过访问 .value,总能得到当前最新 的值。

代码示例:

修改 ExecuteInLongLiveLambda,对需要保持最新的 action1 使用 rememberUpdatedState

@Composable
fun ExecuteInLongLiveLambda(
  action1: () -> Unit,
  action2: () -> Unit,
) {
  Log.d("MainActivity", "ExecuteInLongLiveLambda Composable 重组或首次组合")

  // 使用 rememberUpdatedState "包装" action1
  // updatedAction1 的 .value 总是指向最新传入的 action1 Lambda
  val updatedAction1 by rememberUpdatedState(action1)
  // action2 因为其内部直接读取 State<Int>,天然能获取最新值,通常不需要包装
  // val updatedAction2 by rememberUpdatedState(action2) // 如果 action2 也可能变化且需要最新版,也可同样处理

  LaunchedEffect(Unit) {
    Log.d("MainActivity", "ExecuteInLongLiveLambda 的 LaunchedEffect 启动")
    delay(2000)
    Log.d("MainActivity", "准备执行 action1 和 action2")
    // 执行时,调用 updatedAction1.value()
    updatedAction1() // 现在这里会执行 b()
    action2()        // 这里依然打印 counter = 1
  }
}

修改说明:

  1. ExecuteInLongLiveLambda 内部,我们用 val updatedAction1 by rememberUpdatedState(action1) 创建了一个新的状态引用。
  2. updatedAction1.value 会在每次 ExecuteInLongLiveLambda 重组时,更新为当时传入的 action1 的值。
  3. LaunchedEffect 内部执行时,调用 updatedAction1()(这实际上是调用了 updatedAction1.value())。因为它访问的是 .value,所以能拿到最新的 ::b 函数引用并执行。

安全建议/使用注意:

  • rememberUpdatedState 主要用于那些不应该因为输入值变化而重启 的副作用(如 LaunchedEffect, DisposableEffect)内部,需要引用到可能会变化的状态或 Lambda 的场景。
  • 不要滥用。如果一个 LaunchedEffect 的行为逻辑确实应该 跟随某个状态的变化而重置/重启,那么应该把那个状态直接作为 key 传入,而不是用 rememberUpdatedState 绕过重启。

进阶使用技巧:

思考一下,如果 action1 本身需要接收参数呢?比如 action1: (Int) -> UnitrememberUpdatedState 同样适用:

// 假设 Test 传递的是带参数的 lambda
ExecuteInLongLiveLambda(
  action1 = { value -> Log.e("MainActivity", "action1($value) executed, current action is ${if (action == ::a) "a" else "b"}") },
  action2 = { ... }
)

// ExecuteInLongLiveLambda 内部
@Composable
fun ExecuteInLongLiveLambda(
  action1: (Int) -> Unit, // 参数类型变化
  action2: () -> Unit,
) {
  val updatedAction1 by rememberUpdatedState(action1)

  LaunchedEffect(Unit) {
    delay(2000)
    val someValue = 100 // 假设有个值要传递
    updatedAction1(someValue) // 调用最新的 action1,并传入参数
    action2()
  }
}

方案二:更换 LaunchedEffect 的 Key

另一种确保 LaunchedEffect 使用最新 Lambda 的方法是,把 Lambda 本身(或者能代表其变化的依赖项)作为 key 传给 LaunchedEffect

原理和作用:

LaunchedEffect 的核心机制是:当其 key 参数发生变化时,当前正在运行的协程会被取消,然后重新启动 一个新的协程。在新协程启动时,它自然会捕获当前最新 的 Lambda 参数。

代码示例:

修改 ExecuteInLongLiveLambda,将 action1 作为 LaunchedEffectkey

@Composable
fun ExecuteInLongLiveLambda(
  action1: () -> Unit,
  action2: () -> Unit,
) {
  Log.d("MainActivity", "ExecuteInLongLiveLambda Composable 重组或首次组合")

  // 将 action1 作为 key。当 action1 的引用变化时,Effect 会重启
  LaunchedEffect(action1) { // 注意 key 变成了 action1
    Log.d("MainActivity", "ExecuteInLongLiveLambda 的 LaunchedEffect 启动/重启")
    // 注意:这里的 delay 每次重启都会重新开始计时
    delay(2000)
    Log.d("MainActivity", "准备执行 action1 和 action2")
    action1() // 由于 Effect 因 action1 变化重启了,这里是重启时捕获的新 action1 (::b)
    action2() // action2 不受影响
  }
}

修改说明:

  1. LaunchedEffectkeyUnit 改为 action1
  2. Testaction::a 变为 ::b 时,ExecuteInLongLiveLambda 重组,传入的 action1 参数引用发生变化。
  3. LaunchedEffect 检测到 key (action1) 的变化,会取消当前的协程(如果正在运行),并立刻启动一个新的协程
  4. 新启动的协程捕获的是当时action1,即 ::b
  5. 所以 2 秒后执行时,是新的 ::b 被调用。

使用注意与权衡:

  • 这种方法简单直接,但有个重要的副作用:整个 LaunchedEffect 的协程会重启 。这意味着,如果协程内部有 delay 或者其他需要长时间运行的逻辑,它们都会被中断并重新开始。在我们的例子里,delay(2000) 会在 action 变化后重新计时。
  • 你需要判断这种重启行为是否符合你的需求。如果只是希望执行最新的函数,而不关心/不希望中断当前效果的流程,那么 rememberUpdatedState 是更好的选择。如果当依赖变化时,确实需要取消旧逻辑并重新开始整个副作用流程,那么更新 key 是正确的做法。
  • 如果 key 是一个 Lambda 表达式,要注意 Lambda 的实例稳定性。如果 Lambda 是在 Composable 函数体内直接定义的(如 action1 = { ... }),它在每次重组时都可能是一个新的实例,即使代码文本没变,也可能导致 LaunchedEffect 不必要地频繁重启。通常建议传递稳定的引用(如顶层函数引用 ::aremember 包裹的 Lambda 等)作为 key

总结与关键点回顾

  • LaunchedEffect(key) 启动协程时会捕获其代码块内引用的外部变量或参数的当时值 。如果 key 不变,协程不重启,捕获的值也不会自动更新。
  • 当捕获的值是函数引用 (如 ::a)或其他不可变值时,即使外部状态变化,协程内使用的仍是旧值。
  • 当 Lambda 闭包捕获的是一个指向 State<T> 对象的变量 时,Lambda 执行时通过该变量读取 .value,能获取到 State 对象内部的当前值 ,即使这个值是在 Lambda 创建后、执行前被修改的。这就是 action2 能读到新 counter 的原因。
  • rememberUpdatedState(value) 是解决“长寿副作用中需要引用最新状态或 Lambda”问题的标准方案。它提供一个稳定的 State 引用,其 .value 始终追踪最新的 value,且读取它不会导致重组,传递它也不会导致 LaunchedEffect 重启。
  • 将变化的依赖项作为 LaunchedEffectkey 可以确保副作用使用最新的依赖,但这会导致副作用的整个流程重启 。需要根据具体场景权衡利弊。