
先说一个真实的翻车现场。上个月我在做公司的一款运动打卡 App其中有个“自动暂停记录”的功能用户在户外跑步时App 要每隔五秒检测一次定位点判断是否长时间没移动如果是就自动暂停记录。第一版我用LaunchedEffect(isRecording)起了个轮询协程isRecording一变协程就重启结果经常出现“刚写入的一条轨迹数据还没落库协程就被取消了”的丢数据问题。排查了一下午最后定位到根因协程里读到的状态值要么是旧的要么把状态塞进 key 导致副作用被反复重启。后来换成rememberUpdatedState问题迎刃而解。这篇文章就围绕这个 API 展开把它的使用场景、源码原理、常见坑一次说清楚。无论你是刚接触 Jetpack Compose 的新手还是已经在项目里用了很久、但遇到“协程读不到最新状态”的老人这篇都应该对你有帮助。1. 先搞清楚它到底解决什么问题很多文章一上来就甩 API 签名但我觉得得先让你看到问题现场否则你根本不知道这个 API 是干嘛的背了签名也白搭。1.1 协程错过“最新值”的经典现场看一段最常见的错误代码Composable fun PollingScreen(enabled: Boolean) { LaunchedEffect(Unit) { while (enabled) { delay(1000) // 执行轮询逻辑 } } }这段代码看起来“应该没问题”因为enabled是参数LaunchedEffect(Unit)启动后在协程里读取它理论上应该能拿到最新值。但实际运行起来你会发现一个诡异现象enabled一开始是true轮询跑得好好的等你把enabled改成false这个协程却永远不退出还在继续轮询。原因很简单——LaunchedEffect(Unit)的 block 只会在首次组合时被创建一次它是一个普通的 Kotlin lambdalambda 捕获的是创建那一刻的enabled值。也就是说在协程作用域里while (enabled)的判断始终使用的是捕获时的旧值不是最新的enabled。即使enabled在外部已经重组更新成了false协程里读到的还是第一次组合时的true。这个问题在传统 Android 开发里也有类似场景匿名内部类或 Runnable 捕获外部变量时Java 8 要求变量必须是 effectively final如果你要读最新值就得用 AtomicReference 或数组包装。Compose 的rememberUpdatedState本质上就是帮你做了这个“包装”让你在协程、回调、DisposableEffect 这类非组合作用域里始终能读到最新状态。1.2 为什么不能简单用remember(key)来解决你可能马上想到另一种方案把参数包进remember里用参数本身作为 keyLaunchedEffect(enabled) { while (enabled) { delay(1000) } }把enabled放进 key确实能保证每次enabled变化时协程会取消并用新的值重新启动。但问题也很明显协程一旦重启之前还没执行完的逻辑会直接中断。举个例子轮询过程中可能正在执行“网络请求已发出、等待响应”的中间态或者正在写入本地数据库这时候协程被cancel这些操作就无法优雅收尾。而且如果你的轮询逻辑里有try/finally或需要清理资源重启也会让清理时机变得不可控。另外这个方案还有一个隐藏成本每次enabled变化都触发一次协程启动和取消如果这个状态频繁切换比如用户反复切换开关协程就会被反复销毁重建白白浪费资源。所以我们真正需要的是——协程本身不重启但协程内部读取某个状态时永远拿到最新值。1.3 它和remember的本质差异一句话总结remember缓存的是“旧值”rememberUpdatedState缓存的是“最新的旧值引用”。remember返回的是一个稳定的对象或计算结果它的特点是“不随重组更新除非 key 变化”。而rememberUpdatedState返回的是一个StateT对象这个 State 的引用永远不变但它的.value会在每次重组时被更新为最新值。也就是说rememberUpdatedState把“引用稳定性”和“值的新鲜度”这两个需求拆开处理了引用稳定你可以在协程里安全地持有这个 State 对象不用担心它被替换。值新鲜每次读取.value时拿到的都是最近一次重组时传入的新值。这就引出了它最常见的应用场景在LaunchedEffect、DisposableEffect、回调 lambda、动画回调里读取那些会随外部重组而变化的参数或状态。2. 官方 API 与基本用法场景理解了API 本身非常简单简单到你可能怀疑“就这”但简单背后有设计值得拆开看。2.1 一行代码的接入方式Composable fun PollingScreen(enabled: Boolean) { val currentEnabled by rememberUpdatedState(enabled) LaunchedEffect(Unit) { while (currentEnabled) { delay(1000) // 轮询逻辑 } } }用起来就是一行把enabled传入rememberUpdatedState拿到一个委托出来的currentEnabled然后放心大胆地在协程里用它。这里有个细节值得注意API 返回的是StateT不是T。它刻意不直接返回值而是返回一个状态容器。为什么因为直接返回T的话协程捕获到的依然是对首次调用结果 T 的引用后续更新也只是把这个 T 换成了新对象协程里持有的引用不会自动更新。只有返回StateT让协程持有“状态的引用”每次通过.value去取才能保证每次都取到最新的值。用by委托的时候currentEnabled本质上就是state.value。你在协程里读currentEnabled实际读的是State.value。因为读取发生在协程恢复执行时所以拿到的是最新值。顺带提一句这个 API 是在androidx.compose.runtime包下的属于 Compose 最基础的工具之一。Compose 1.0 时代它就已经存在了而且到现在语义都没变过非常稳定。2.2 它和remember、derivedStateOf的分工思路不清晰的人容易把rememberUpdatedState跟remember、derivedStateOf混在一起。我建议直接记一张对照表平时写代码前先想清楚自己到底需要哪种能力API返回类型特点典型用途remember任意类型 T引用稳定只有 key 变化才重新计算缓存耗时计算结果、保持单例对象rememberUpdatedStateStateT引用稳定但 value 每次都更新在协程/回调中读取最新参数derivedStateOfStateT由其他状态派生自动计算根据多个状态得到衍生值驱动 UImutableStateOfMutableStateT真正可读写的状态源UI 状态管理驱动重组注意rememberUpdatedState和derivedStateOf的区别前者不“计算”任何东西它只是把外部传入的值“搬运”到一个稳定的 State 容器里后者是从已有状态推导出新值每次推导都会在组合阶段完成返回的 State 会驱动重组。两者的用途天差地别千万别搞混。3. 源码级拆解那两行代码为什么这么神奇rememberUpdatedState的源码比你想的要短得多整个函数体就两行核心逻辑。但这两行背后的机制值得仔细琢磨。3.1 逐行读源码源码大致是这样的以 Compose 1.x 为例Composable fun T rememberUpdatedState(newValue: T): StateT remember { mutableStateOf(newValue) }.also { it.value newValue }第一行remember { mutableStateOf(newValue) }注意 remember 没有传任何 key这意味着无论newValue怎么变它都不会重新执行初始化。换句话说mutableStateOf只会在首次组合时创建一次之后每次重组返回的都是同一个MutableState对象。第二行.also { it.value newValue }这句在每次重组时都会执行作用是把新的newValue直接赋给之前创建的那个 MutableState 的 value。这样一来同一个 State 对象的 value 就永远是最新的。再看一下调用方式val currentEnabled by rememberUpdatedState(enabled)by委托会调用getValue合成的currentEnabled取值表达式等价于state.value。在协程里读它读的是上一次重组时写入的最新值。这就是“两行代码”的魔力一个remember保证引用稳定一个also保证值最新两者缺一不可。3.2 不触发重组的真正原因以及一个致命误用回到开头提到的那个关键问题为什么rememberUpdatedState不会触发重组因为它内部的mutableStateOf写入值发生在also这一步而这一步发生在组合阶段。当下一次重组发生时also写入新值如果组合过程中有 UI 读取了这个值由于写入和读取发生在同一个快照事务内Compose 的快照系统不会因为这次写入而安排额外的重组。它不会引起“状态变更 → 触发重组”的连锁反应。换句话说rememberUpdatedState返回的 State本意是给那些“非组合作用域”协程、回调、DisposableEffect 的 onDispose、动画回调读取的它不是一个合格的 UI 状态源。因此有个致命误用必须提醒你不要用它来驱动 UI。// 错误示例千万不要这样写 val currentName by rememberUpdatedState(name) Text(currentName) // 这里可能显示旧的 name且不触发重组如果你在组合作用域比如Text的参数里直接读rememberUpdatedState的 value大概率会得到旧值更严重的是它不会触发重组。想要驱动 UI请用普通mutableStateOf或derivedStateOf。这一点是我在实际项目里踩过坑的刚开始我把rememberUpdatedState当成“能自动更新 UI 的 remember”结果列表无论如何都不刷新查了半天才意识到方向错了。4. 高频场景实操与踩坑记录理论说完了下面进入最实用的部分——三个高频场景的完整实操。我会把代码写全并标出每一个我踩过的坑。4.1 轮询与定时任务协程永不再重启回到开头那个运动打卡 App 的场景。修复后的代码长这样Composable fun RecordingScreen( isRecording: Boolean ) { val currentRecording by rememberUpdatedState(isRecording) LaunchedEffect(Unit) { while (currentRecording) { delay(5000) // 采集一次定位点并写入本地数据库 uploadLocation() } } }关键改动有两个把isRecording从LaunchedEffect的 key 里拿掉改成LaunchedEffect(Unit)确保协程只启动一次。用rememberUpdatedState包装isRecording让协程里的currentRecording在每次delay恢复后读取最新值。这样当用户点击“结束记录”时isRecording变成false下一次协程从delay恢复后就会立刻判断currentRecording false退出循环。整个过程中协程没有被取消之前执行到一半的逻辑可以安全地走完。不过这里有个实际体验问题如果协程刚好在 delay 中状态变化并不能“立刻”终止循环。比如delay(5000)已经执行了 4 秒用户在这时关闭了记录协程还要等剩余 1 秒才能读到新值并退出。如果你需要“秒停”就得引入另一个协程控制取消或者用snapshotFlow监听状态变化后调用job.cancel()。我的做法是在关闭按钮里直接持有Job点击时显式cancel()并在finally块里做收尾工作。这样响应是瞬时的rememberUpdatedState则负责处理那些“不需要秒停”的业务逻辑比如等待当前定位点落库。4.2 事件回调透传让子组件拿到“最新回调”第二个高频场景是把回调函数传给子组件同时避免子组件因为 lambda 变化被反复重组。很多下拉刷新列表组件都会这样设计Composable fun PullRefreshLayout( onRefresh: () - Unit ) { val currentOnRefresh by rememberUpdatedState(onRefresh) // 这里假设有个内部协程可能在某些状态变化后调用刷新 LaunchedEffect(Unit) { // 模拟等待一个条件成立后触发刷新 while (!isSomeCondition) { delay(100) } currentOnRefresh.invoke() } }为什么不直接在回调参数里用onRefresh()因为onRefresh是父组件每次重组都可能创建的新 lambda如果子组件直接把它存入一个remember的容器的里容器保存的是首次的旧 lambda后续父组件更新了刷新逻辑子组件调用的依然是旧逻辑这会导致隐藏 bug。用rememberUpdatedState包装后lambda 的引用虽然还是稳定的 State但调用时读取的是父组件最新一次传进来的 lambda逻辑永远是最新的。这个模式尤其适合那些“真正扛着业务逻辑”的长生命周期组件比如自绘轮播图、播放器控制层、地图标注管理器等。顺带提一个配套场景DisposableEffect的onDispose里也经常需要读取最新值。比如某个页面销毁时要上报埋点上报的参数来自父组件的状态如果直接在onDispose里用捕获的旧 lambda会拿到过期数据。DisposableEffect(Unit) { onDispose { // 如果直接使用 action可能是旧值用 currentAction.value 拿到最新 currentAction.invoke() } }这个细节很多人会漏掉但实际排查时非常容易中招。4.3 动画结束后的收尾动作第三个场景是动画回调。Compose 的Animatable、animate*AsState等 API 允许你挂载回调但回调里读取的状态经常是“动画开始时”的旧值。举个例子val currentTarget by rememberUpdatedState(targetValue) val animatable remember { Animatable(0f) } LaunchedEffect(Unit) { animatable.animateTo( targetValue currentTarget, animationSpec tween(500) ) { // 这里如果用 targetValue可能读到旧值用 currentTarget 才是最新 if (currentTarget 100f) { // 做一些额外处理 } } }animateTo的 lambda 是在动画帧回调里执行的它捕获的targetValue是动画启动瞬间的值。如果动画执行过程中用户拖动了进度条targetValue已经变了但回调里读到的依然是旧值。这时候用rememberUpdatedState包一层就能保证每次帧回调都读到最新目标值。5. 使用边界与常见误区明白了它能做什么还得知道它不能做什么。这块我按“我踩过的坑”和“别人踩过的坑”整理一下。5.1 别在组合作用域里拿它驱动 UI前面已经强调过这里再单独拎出来说一下。一个比较典型的错误Composable fun BadExample(name: String) { val currentName by rememberUpdatedState(name) // 下面的 Text 依赖于 currentName但 rememberUpdatedState 不保证重组触发 Text(currentName) }你期望的效果是name变化后Text跟着变但rememberUpdatedState的赋值发生在组合阶段且这个赋值不会触发新一轮重组。最终结果可能是 Text 显示了旧值或者恰好因为父组件重组而顺带更新但行为完全不可控。在 UI 组合作用域里读取状态请一律使用普通的mutableStateOf、derivedStateOf或者直接把参数传入。5.2 普通类里没有rememberUpdatedState怎么办有朋友问过我能不能在 ViewModel 里用rememberUpdatedState答案是不能。它是一个Composable函数只能在可组合函数内调用。如果你需要在 ViewModel 或普通业务类里维护“最新值”应该使用StateFlow或mutableStateOf加上合适的收集机制。其实在 Android 架构组件中ViewModel 里维护 UI 状态的标准做法就是StateFlow当 Compose 需要收集它时用collectAsStateWithLifecycle。rememberUpdatedState解决的是“同一个 Composable 内部把参数传入副作用作用域”这个窄问题不要把它当成全局状态方案。5.3 别用它替代协程的“取消机制”另一个误区是把rememberUpdatedState当成“即时取消”的灵丹妙药。前面说过协程只有在挂起点恢复时才会读取最新值。如果业务需要“状态一变就立刻中断当前操作”光靠rememberUpdatedState是不够的。// 如果 delay 很长状态变化后无法立刻中断 while (currentRecording) { delay(60_000) // 如果 user 在 59 秒时关闭这里要等 1 分钟才能退出 doHeavyWork() }这种场景我建议用前置的snapshotFlow监听状态、配合 Job 控制取消或者把 delay 切成多个短 delay 轮询判断当前状态。我实际项目里的经验是短轮询比如 1 秒内的 delay配合rememberUpdatedState已经足够应对 90% 的场景真正的长任务必须用结构化并发来管理。5.4 别和remember的功能重叠还有一种误用是试图用rememberUpdatedState替代remember来缓存计算结果。比如val expensiveResult by rememberUpdatedState(computeResult(data))这是不对的。computeResult(data)本来在每次重组时都会执行因为rememberUpdatedState的参数表达式在重组时会被求值你根本没有做到缓存。结果是耗时计算照样跑还塞了一个不会触发重组的 State 容器完全是负优化。耗时计算请用remember(data) { computeResult(data) }职责不同不要混用。6. 高频问题排查速查表最后把我在业务和社区里见过的高频问题整理成一个速查表方便你排查时对照。问题现象可能原因解决办法协程里读到的总是初始值LaunchedEffect的 block 捕获了旧参数用rememberUpdatedState包装参数再读取状态一变协程就重启、逻辑丢失把易变状态直接放进了LaunchedEffect的 keykey 改成Unit用rememberUpdatedState读取状态用rememberUpdatedState但 UI 不刷新在组合作用域里读它的 value 驱动 UI换成mutableStateOf或derivedStateOf回调执行时用的是上次的旧 lambda直接把回调 lambda 存入 remember用rememberUpdatedState包装回调 lambdaViewModel 里想用rememberUpdatedState该 API 是 Composable 函数改用StateFlowcollectAsStateWithLifecycle状态变化后协程没有“立刻”退出协程处于挂起点还没恢复读取配合 Job cancel 或用更短的轮询间隔再补充两个小技巧技巧一确认协程到底有没有被重启。在LaunchedEffect第一行加一个日志观察重组时日志是否被重复打印LaunchedEffect(Unit) { Log.d(Polling, LaunchedEffect started) while (currentRecording) { delay(5000) // ... } }如果日志打印了多次说明协程曾反复重启大概率就是你某个状态被误放进了 key。如果你发现日志只打印一次但读到的值是旧的那才是捕获旧值的问题此时引入rememberUpdatedState才是正解。技巧二优先用snapshotFlow观察状态变化。如果你需要“状态一变化就做某事”rememberUpdatedState的读取时机依赖协程挂起点不是最优解。改成LaunchedEffect(Unit) { snapshotFlow { isRecording } .distinctUntilChanged() .collect { recording - if (recording) { // start polling } else { // stop polling } } }这种方式能即时响应每次状态变更比在 while 循环里轮询读currentRecording更精准。实际项目中我会把两者配合使用外部状态变化用snapshotFlow驱动开始/停止rememberUpdatedState则负责在协程内部读取那些“不需要秒级响应”的参数。写在最后说实话rememberUpdatedState是我初期最容易忽略的 Compose API 之一——它太简单了简单到第一次看到源码时我觉得“就这”但用下来才意识到它解决的是一个非常本质的问题状态在重组中是流动的而协程和回调捕获的是静止的快照。这两个世界的冲突被这一层薄薄的 State 容器化解了。我现在写 Compose 时有一个下意识的习惯凡是写LaunchedEffect第一件事就是看 block 里有没有引用外部的可变参数凡是写DisposableEffectonDispose里引用的状态都会用rememberUpdatedState包装一遍。这两个习惯帮我挡掉了很多隐蔽的 bug。另外一个小建议如果你的项目里引入了一个自定义组件组件的回调会被挂在长生命周期对象比如传感器监听、数据库观察者上那么给回调包裹rememberUpdatedState基本是板上钉钉的事。这不是性能优化这是正确性保证——它省掉的是你在协程深处排查“为什么读到的还是旧值”的数个小时。