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 和取值位置。


