卷 V · 接缝CH 17深度 17/24

LaunchedEffect:key 变了就重来

Compose 和协程的第一个接缝。LaunchedEffect 一共只有一条规则,但这条规则的两个方向都能出事:key 写少了,你会拿着过期的参数发请求;key 写多了,你会白白重发一堆请求。

LaunchedEffectkeyrememberUpdatedState副作用

为什么需要它

先明确一件事: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 里读了 idid 就必须在 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 对象写进 keyLaunchedEffect(uiState) { … }——uiState 里任何一个字段变了它都重启。应该只写它真正依赖的那个字段。
  • 把 lambda 写进 keyLaunchedEffect(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 变了整段丢弃重建取消旧协程,起新协程
用在「这是另一个东西了」「这件事的参数变了」

大多数时候你只需要后者。前者是重武器——它会把滚动位置、动画状态、输入框内容全部清掉。

⌗ 到你手上

这一章的一句话

key 的意思是「什么变了算换了一件事」。effect 块里读到的外部值都该在 key 里;想不重启又要最新值,就用 rememberUpdatedState 把它换成一个盒子。

下一章:LaunchedEffect 只是 Effect 家族里的一个。剩下几个各管一段,而分工的逻辑其实只有两条线——要不要协程、要不要清理