LaunchedEffect:key 变了就重来
Compose 和协程的第一个接缝。LaunchedEffect 一共只有一条规则,但这条规则的两个方向都能出事:key 写少了,你会拿着过期的参数发请求;key 写多了,你会白白重发一堆请求。
为什么需要它
先明确一件事:composable 的函数体里不能写副作用。
// ❌ 这段代码会发多少次请求?答:不知道
@Composable
fun Profile(id: Int) {
val user = api.load(id) // 每次重组都会跑一遍
Text(user.name)
}
前面十六章已经把理由说清楚了:这个函数会被跑很多次、可能被跳过、可能跑到一半被丢弃,你对「跑几次」没有任何保证。
所以 Compose 提供了一组 Effect API,它们的共同点是:有明确的、你能预测的执行时机。LaunchedEffect 是其中最常用的一个。
@Composable
fun Profile(id: Int) {
var user by remember { mutableStateOf<User?>(null) }
LaunchedEffect(id) { // ← 只有 id 变了才重新跑
user = api.load(id) // 这里面是一个协程
}
Text(user?.name ?: "加载中")
}
它的一条规则
LaunchedEffect(key1, key2, …) { block }
进入组合 → 开一个协程跑 block
重组,key 没变 → 什么都不做(协程继续跑着)
重组,key 变了 → 取消旧协程 → 开新协程跑 block
离开组合 → 取消协程
就这四行。所有的问题都出在第三行的 key 该写什么。
key 少了:拿着旧参数干活
在 demo 里选第二个选项 LaunchedEffect(Unit),然后点「换个用户」:
userId 从 1 变成 2 LaunchedEffect(Unit):启动 0 次、取消 0 次 —— effect 没有重启。它还在给老的 id 发请求, 或者更糟:它已经跑完了,用户看到的永远是第一个人的资料。
LaunchedEffect(Unit)(以及 LaunchedEffect(true))的意思是「只在进入组合时跑一次,之后无论如何都不重来」。它有正当用途——比如「页面打开时播一个入场动画」——但它绝对不能用在「跟着某个参数走」的场景上。
怎么发现自己犯了这个错:看 effect 块里用到了哪些外部变量,它们是不是都在 key 里。如果 block 里读了 id,id 就必须在 key 里。
Android Studio 有一个 lint 检查会提醒这件事,但不总是能覆盖。更可靠的土办法:把 effect 块单独抽成一个函数。
LaunchedEffect(id) { loadProfile(id) }
private suspend fun loadProfile(id: Int) { … }
抽出来之后,「这个块依赖哪些外部值」就变成了「这个函数有哪些参数」——一眼就能看出来 key 该写什么。这是这一章最实用的一条写法建议。
key 多了:白白重启
选第三个选项 LaunchedEffect(userId, theme),然后点「切换主题」:
主题从「浅色」变成「深色」 LaunchedEffect(userId, theme):启动 1 次、取消 1 次 —— 主题和这个网络请求毫无关系,但你把它写进 key 了, 于是刚发出去的请求被取消,又重发了一遍。
这个错误的形态往往更隐蔽,因为它不会显示错误的数据,只是多花了流量、多转了一次圈。最常见的两个来源:
- 把整个
state对象写进 key:LaunchedEffect(uiState) { … }——uiState里任何一个字段变了它都重启。应该只写它真正依赖的那个字段。 - 把 lambda 写进 key:
LaunchedEffect(onDone) { … }——在强跳过之前,lambda 每次重组都是新对象,这等于「每次重组都重启」。
那我就是想在 effect 里用最新的 lambda 怎么办
这是一个真实的两难:
// 需求:进页面 3 秒后自动跳走,跳去哪由外面决定
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
LaunchedEffect(???) {
delay(3000)
onTimeout() // ← 想用「最新的」那个
}
}
- key 写
onTimeout→ 它一变就重启,3 秒重新计时,可能永远跳不走 - key 写
Unit→ 不重启,但闭包捕获的是第一次那个 lambda,如果外面换了新的行为,用的还是老的
标准答案是 rememberUpdatedState:
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout) // ← 一个会被悄悄更新的盒子
LaunchedEffect(Unit) {
delay(3000)
currentOnTimeout() // 读盒子 → 永远是最新的那个
}
}
它的实现只有两行:
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue } // ← 每次重组都把最新值塞进去
思路是「把值换成一个盒子」:effect 捕获的是盒子(永远不变),读到的是盒子里的东西(永远最新)。
key 的语义是一句话:「什么变了,算换了一件事。」
- effect 块里读到的每一个外部值,都应该在 key 里
- 除非你希望它「不重启,但用最新值」——那就用 rememberUpdatedState 把它包起来
两条合起来,覆盖了 LaunchedEffect 全部的正确用法。
① 用 LaunchedEffect 响应点击
// ❌ 为了「点击后做事」造了一个 state 出来
var clicked by remember { mutableStateOf(false) }
LaunchedEffect(clicked) { if (clicked) { doSomething(); clicked = false } }
Button(onClick = { clicked = true }) { … }
// ✅ 事件驱动的事情用 rememberCoroutineScope
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { doSomething() } }) { … }
判据:这件事是「进入某个状态就该发生」还是「用户干了什么才发生」?前者用 LaunchedEffect,后者用 rememberCoroutineScope。
② 在 LaunchedEffect 里读 state 期望它跟着变
// ❌ 协程不是组合,这里的读取不会建立订阅
LaunchedEffect(Unit) {
while (true) { log(scrollOffset.value); delay(1000) } // 每次读的是当时的值,OK
}
// 但如果你想「一变就做点什么」,得用 snapshotFlow(第 20 章)
LaunchedEffect(Unit) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { loadMoreIfNeeded(it) }
}
③ 忘了它会被取消
LaunchedEffect 里的协程在这段 composable 离开组合时被取消。所以第 11 章那条规矩在这里一样成立:如果块里有纯 CPU 的长循环,取消不掉。而 composable 离开组合是非常频繁的事(列表滚动、tab 切换、导航)。
它和第 3 章的 key 是什么关系
值得把两个 key 放在一起看,因为它们经常同时出现:
key(pageId) { // ← 第 3 章的 key:换身份
Page(pageId) // 整段 group 作废重建
}
@Composable
fun Page(id: String) {
LaunchedEffect(id) { … } // ← 这一章的 key:重启 effect
}
差别:
key(k) { } | LaunchedEffect(k) { } | |
|---|---|---|
| 影响范围 | 整段 group:所有 remember、所有 effect | 只有这一个 effect |
| k 变了 | 整段丢弃重建 | 取消旧协程,起新协程 |
| 用在 | 「这是另一个东西了」 | 「这件事的参数变了」 |
大多数时候你只需要后者。前者是重武器——它会把滚动位置、动画状态、输入框内容全部清掉。
在项目里搜 LaunchedEffect,逐个过:
1. key 是 Unit / true 吗?
是 → 块里有没有读外部变量?读了就是 bug(除非包了 rememberUpdatedState)
2. key 里有没有整个 state 对象、或者 lambda?
有 → 换成它真正依赖的那几个字段
3. 块里有没有纯 CPU 的长循环?
有 → 加 ensureActive(),否则页面走了它还在跑
4. 这个 effect 是不是在响应「用户操作」?
是 → 应该用 rememberCoroutineScope + onClick,不是 LaunchedEffect
这一章的一句话
key 的意思是「什么变了算换了一件事」。effect 块里读到的外部值都该在 key 里;想不重启又要最新值,就用 rememberUpdatedState 把它换成一个盒子。
下一章:LaunchedEffect 只是 Effect 家族里的一个。剩下几个各管一段,而分工的逻辑其实只有两条线——要不要协程、要不要清理。