副作用必须有钥匙,也必须有墓碑
Composition 可以被试算、放弃、重跑;任何会碰外部世界的动作都必须明确何时开始、何时重启、何时清理。
页面显示某个会话时开始监听输入状态;切换会话应取消旧监听并启动新监听。该用:
- 直接在 Composable 函数体里
launch LaunchedEffect(conversationId)LaunchedEffect(Unit)remember { repository.observeTyping() }
副作用是描述之外的承诺
写日志、发网络、注册监听、导航、启动协程,都会改变 Composition 之外的世界。若直接放在函数体,重组几次就执行几次;而一次重组还可能被放弃,外部动作却收不回来。
LaunchedEffect(key) 在进入 Composition 时启动协程,key 改变时取消旧协程并重启,离开时取消。key 不是为了满足 API,而是在声明「哪些输入变化会让旧工作失效」。会话 ID 改变,旧会话监听当然失效;主题颜色改变,不应重连网络。
按寿命选 Effect
| 需求 | 工具 | 退出时 |
|---|---|---|
| 进入后运行 suspend 工作 | LaunchedEffect | 自动取消 |
| 点击后启动动画或 Snackbar | rememberCoroutineScope | 作用域随组合取消 |
| 注册/注销监听器 | DisposableEffect | onDispose |
| 把 Compose 状态推给外部对象 | SideEffect | 每次成功提交后执行 |
| 把回调中的最新值给长寿任务 | rememberUpdatedState | 不重启任务 |
rememberUpdatedState 解决一个细问题:Effect 的寿命不该随某个 lambda 改变,但任务执行时又必须调用最新 lambda。例如欢迎页倒计时只启动一次,完成回调可能因父级重组而更新;用它获得最新回调,不必重启计时。
生命周期和 Composition 是两套边界
Composable 在 Composition 中,不代表宿主 Activity 正在前台。页面进了返回栈但不可见时,Composition 可能仍存在。Android 上收集 UI 状态优先使用 collectAsStateWithLifecycle(),让订阅至少在 STARTED 生命周期才活跃。
反过来,Composition 的局部元素可能比 Activity 短得多。某个对话框里的倒计时应该绑 LaunchedEffect,而不是 Activity 的 lifecycleScope,否则对话框消失后工作还在。
@Composable
fun TypingIndicator(
conversationId: String,
repository: ChatRepository
) {
var typing by remember { mutableStateOf(emptySet<String>()) }
LaunchedEffect(conversationId, repository) {
repository.observeTyping(conversationId)
.collect { typing = it }
}
Text(if (typing.isEmpty()) "" else "有人正在输入…")
}
LaunchedEffect(true) 或 LaunchedEffect(Unit) 时不要条件反射说错;先确认它是否真的要与这次调用位置同寿。若内部捕获会变化的值,再问应该重启还是用 rememberUpdatedState。写一个带清理的生命周期观察器。切页面或改 owner 后,旧 observer 必须移除:
进入页面 → observer added 退到后台 → ON_STOP 离开页面 → observer removed
@Composable
fun LifecycleProbe(
onEvent: (Lifecycle.Event) -> Unit
) {
val owner = LocalLifecycleOwner.current
val currentOnEvent by rememberUpdatedState(onEvent)
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event ->
currentOnEvent(event)
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}在 Android Studio 运行,打开日志后前后台切换,再导航离开。把 onDispose 暂时删掉,观察重复进入页面后监听数量怎样增长,然后立刻还原。
应用内相机预览、传感器监听和 WebSocket 订阅都有真实资源。它们不能只「开始成功」,还要在预览离开、权限撤回、会话切换时可证明地停止。医疗场景里,忘记清理不仅耗电,还可能在错误页面继续处理敏感流。
「只要包进 LaunchedEffect,就不会重复执行。」key 改变会重启;调用位置离开再进入也会重启;key 过宽或过窄都可能错。
为副作用写一句寿命合同:「当 X 存在且 Y 不变时运行;Y 改变或 X 离开时取消。」然后把 X 映射到调用位置,把 Y 映射到 key。
答案是第 2 项。会话 ID 定义监听身份,变化时旧监听已无意义,应取消并重启。Unit 不会随会话切换重启,直接在函数体启动则无法与 Composition 安全对齐。
这一章的一句话
每个副作用都要能回答:以什么为身份,何时取消,清理动作在哪里。
下一章落到消息页最吃功夫的地方:一万条消息不能只「放进 LazyColumn」。同一条记录必须有稳定 key,时间分隔线有不同 content type,大屏还会把一列拆成两栏。