DEBUG.WIKI
京东云;让 AI 进入真实业务;零售 · 物流 · 客服 · 硬件;查看方案 →
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
Evoxt;最高 6.0GHz CPU;高频云服务器 · 免费每周备份;查看配置 →

LaunchedEffect 仍调用旧回调:区分 effect key 和 rememberUpdatedState

LaunchedEffect 等待结束后调用旧回调时,区分任务需要重启与回调需要更新,用 rememberUpdatedState 在执行时读取最新值,并核对 key 的业务含义。

页面启动一个延迟任务,期间父组件已经传入新的回调,延迟结束后却仍执行最初那一个。常见写法是 LaunchedEffect(Unit) { delay(...); onTimeout() }:effect 的 key 没变,正在运行的协程也没有重建,因此其中捕获的普通参数可能仍属于上一次组合。

下文适用于 Jetpack Compose 1.x 的 LaunchedEffect 和 rememberUpdatedState,以 Kotlin 协程和 Material 3 展示问题。这里讨论的是长时间运行的 effect 如何读取更新后的参数,不涉及通过后台服务保证计时,也不保证页面退到后台时暂停倒计时。

先确认你希望“重新开始”,还是“完成时用最新回调”

假设通知显示 10 秒后调用回调。显示期间,父组件把回调从记录 A 改成记录 B:

  • 如果这次变化代表一条新通知,应取消旧等待并重新计时,把通知标识加入 effect key。
  • 如果仍是同一条通知,只是完成时应采用新的处理逻辑,就保持计时,使用 rememberUpdatedState 读取最新回调。

两种行为都可能合理。把所有参数一律加进 key,会使普通界面更新反复重启任务;把所有参数都藏进 rememberUpdatedState,又可能让本应重新开始的任务继续运行。

同一条通知继续计时,结束时读取当前回调

下面组件用 noticeId 表示一次通知,用 waitMillis 表示本次等待长度。回调变更不重启,通知或等待长度变更则重新开始:

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay

@Composable
fun TimedNotice(
    noticeId: String,
    waitMillis: Long,
    onTimeout: () -> Unit
) {
    require(waitMillis >= 0L)
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(noticeId, waitMillis) {
        delay(waitMillis)
        currentOnTimeout()
    }

    Text("通知 $noticeId")
}

rememberUpdatedState 保留一个状态持有者,每次组合时更新其中的值。协程到达 currentOnTimeout() 时才读取它,因此可以使用最近一次组合传入的回调,且不需要重新等待。

以下父组件可帮助观察回调替换。label 是每次组合计算出的普通字符串,新的 lambda 捕获新的值:

import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun NoticeExample() {
    var useNewCallback by remember { mutableStateOf(false) }
    val label = if (useNewCallback) "B" else "A"

    Column {
        Button(onClick = { useNewCallback = true }) {
            Text("改为回调 B")
        }
        TimedNotice(
            noticeId = "notice-1",
            waitMillis = 10_000L,
            onTimeout = { Log.d("NoticeExample", label) }
        )
    }
}

在等待结束前点击按钮,预期是本次等待结束时记录 B,而不是从点击时再等待 10 秒。把 TimedNotice 中的调用改回直接使用参数 onTimeout(),便会重新引入旧回调的风险。实际表现还取决于回调捕获了什么:如果 lambda 自己读取的是一个持续更新的 State 对象,它可能已经能看到新值,不能仅凭一次日志就认定所有回调都会过期。

不要提前把最新值又存成普通局部变量

下面的写法仍会固定等待开始时的回调:

LaunchedEffect(noticeId, waitMillis) {
    val callbackAtStart = currentOnTimeout
    delay(waitMillis)
    callbackAtStart()
}

关键是需要执行时读取状态。rememberUpdatedState 也不能修复父组件根本没有更新参数的问题;如果父级写了 remember { onTimeout },始终传入最初的 lambda,子级得到的仍是旧回调。

如果 effect 正在加载 documentId 对应的数据,切换文档通常意味着旧任务应结束、新任务应启动。此时应使用 LaunchedEffect(documentId),不能只把 documentId 包成“最新值”,让旧请求与新页面混在一起。对完成回调、监听处理函数等不改变任务身份的参数,才考虑保留任务并读取最新值。

检查任务重启和组件退出两条边界

验证时分别观察:只替换回调,原等待是否继续;改变 noticeId 或 waitMillis,旧等待是否取消、新等待是否开始;把组件移出 Composition,是否不再收到这次延迟回调。重新进入 Composition 会产生新任务,Unit 这样的常量 key 也不代表“整个应用只执行一次”。

示例中的 delay 支持协程取消。若接入外部监听器或自行启动了不属于该协程的工作,还需要按其 API 解除注册或取消,不能期待 rememberUpdatedState 代办清理。需要注册和注销成对处理的资源,可考虑 DisposableEffect。

这次修复会改变回调选取或重启时机。若业务原本要求保留任务开始时的快照,应明确保留该快照,并把参数命名与行为写清楚;不能为了让日志变成 B 而改变业务语义。回退时也应同时核对 key 和取值位置。