五个 Effect,各管一段
Effect API 看起来有五六个,记起来很烦。但它们的分工其实只有两条线,画一个二乘二的格子就装得下——而且那条最重要的规矩根本不在这五个里面,它是一句「不要」。
先说那句「不要」
composable 的函数体里,不要写任何有副作用的代码。
「副作用」的定义:任何能被这个函数之外的东西观察到的变化。发网络请求、写数据库、往列表里 add、改一个外部变量、打日志、埋点、启动一个协程——全算。
理由前面十七章已经铺完了:
- 它会被跑很多次(第 1 章)
- 它可能被跳过,一次都不跑(第 4 章)
- 它可能跑到一半被丢弃(第 3 章:key 变了、分支切了)
- 它跑在哪个线程、按什么顺序、跑几次,都不保证
所以副作用需要一个「有确定时机」的容身之处。这五个 API 就是那些容身之处。
二乘二的格子
不需要清理 需要清理(有反操作)
┌──────────────────────┬──────────────────────┐
需要协程 │ LaunchedEffect │ DisposableEffect │
(能挂起、能取消)│ produceState │ (+ awaitDispose) │
├──────────────────────┼──────────────────────┤
不需要协程 │ SideEffect │ DisposableEffect │
│ │ │
└──────────────────────┴──────────────────────┘
另外两个不在格子里:
rememberCoroutineScope() —— 给「事件驱动」的场合
rememberUpdatedState() —— 不是 effect,是一个补丁(第 17 章)
一个一个说
LaunchedEffect(keys) { }
第 17 章讲透了。一句话:「进入这个状态就该开始做的、可挂起的事」。
DisposableEffect(keys) { … onDispose { } }
它是唯一一个强制你写清理逻辑的(不写 onDispose 编译不过)。这个强制很有价值——它让你没法忘。
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer) // ← 必须写
}
}
触发时机和 LaunchedEffect 一样(key 变了 / 离开组合),差别只在于:它不是协程(块里不能调 suspend 函数),但它有 onDispose。
典型用途:注册/注销监听器、申请/释放资源、锁屏常亮、保持屏幕方向、订阅一个非协程的 SDK 回调。
判据一句话:这件事有「反操作」吗?有 → DisposableEffect。
SideEffect { }
最简单的一个:每次成功重组之后跑一次。没有 key,不能挂起,没有清理。
SideEffect {
analytics.setUserProperty("plan", user.plan) // 同步给一个非 Compose 的对象
}
它和「直接写在 body 里」的差别是:只有组合真的成功了才会跑。组合失败、被丢弃、被跳过,它都不跑。
它的适用面很窄——只用来把 Compose 里的值同步给一个非 Compose 的世界。如果你发现自己在 SideEffect 里做别的事,多半选错了。
produceState(initialValue) { }
LaunchedEffect + mutableStateOf 的合体写法:
// 这两段等价
val user by produceState<User?>(initialValue = null, id) {
value = api.load(id)
awaitDispose { /* 可选的清理 */ }
}
// 等价于
var user by remember { mutableStateOf<User?>(null) }
LaunchedEffect(id) { user = api.load(id) }
它的价值是把「一个异步来源」直接变成一个 State,尤其适合包装回调式 API:
@Composable
fun locationState(): State<Location?> = produceState<Location?>(null) {
val listener = LocationListener { value = it }
locationManager.requestUpdates(listener)
awaitDispose { locationManager.removeUpdates(listener) }
}
坦白说,日常业务里用得不多——因为大多数异步来源已经是 Flow 了,直接 collectAsStateWithLifecycle() 更省事。
rememberCoroutineScope()
唯一一个不是 effect 的成员。它给你一个绑在当前 Composition 上的 CoroutineScope,让你能在事件回调里启动协程。
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Button(onClick = {
scope.launch { snackbarHostState.showSnackbar("已保存") }
}) { Text("保存") }
这个 scope 会在这段 composable 离开组合时被取消——所以它和 LaunchedEffect 的生命周期是一样的,差别只在「谁来触发」:
LaunchedEffect 由状态触发:进入组合 / key 变了 rememberCoroutineScope 由事件触发:用户点了、拖了、划了
- 要发请求/能挂起,参数变了要重来 → LaunchedEffect(参数)
- 这件事有反操作(注册/注销、申请/释放) → DisposableEffect
- 用户点了才做 → rememberCoroutineScope + launch
- 把值同步给非 Compose 的对象 → SideEffect
- 把回调式来源变成 State → produceState
三个几乎人人踩过的坑
① 在 body 里 launch
// ❌ 每次重组都开一个新协程,而且没人取消它们
@Composable
fun Screen(scope: CoroutineScope) {
scope.launch { load() } // 一秒重组 60 次 = 60 个协程
}
这个错误的症状是「请求发了几十次」或者「内存慢慢涨」。rememberCoroutineScope() 拿到的 scope 也一样不能这么用——它只能在回调里用。
② DisposableEffect 里写 suspend 函数
编译就过不了(块不是 suspend 的)。如果你既要协程又要清理,两条路:
- 用
LaunchedEffect,清理写在协程的try/finally里——协程被取消时finally会跑(记得第 11 章的NonCancellable) - 用
produceState的awaitDispose
③ 用 SideEffect 更新 Compose 自己的 state
// ❌ 无限重组
SideEffect { count = count + 1 } // 改 state → 重组 → SideEffect 又跑 → …
SideEffect 是「组合完成之后」跑的,在里面改 state 会立刻触发下一次组合。这是一个能把 App 卡死的写法,而且它不报错,只是 CPU 跑满。
这类 bug 有一个统一的形状:「组合的过程中改变了组合的输入」。
// ❌ 这几种都是同一个错误的变体
@Composable
fun Bad(items: List<Item>) {
if (items.isEmpty()) showEmpty = true // 在 body 里改 state
val sorted = items.sortedBy { it.time } // 每次新对象(不算错,但会破坏跳过)
LaunchedEffect(sorted) { … } // 于是 effect 每次都重启
}
Compose 有一个运行时检查会在极端情况下报 IllegalStateException,但大多数时候它不会报错,只是让你的 App 无限重组。
发现它的办法在第 23 章:把重组高亮打开,看哪块区域一直在闪。
顺带说:Effect 和 Flow 的收集
你会看到两种写法都在用:
// 写法 A:收成 State(最常用)
val state by vm.uiState.collectAsStateWithLifecycle()
// 写法 B:在 effect 里收(需要对每个值做副作用时)
LaunchedEffect(Unit) {
vm.events.collect { event -> handle(event) }
}
分工很清楚:
- 这个流表达的是「当前是什么样」 → 写法 A,收成 State 去渲染
- 这个流表达的是「发生了什么」,每个值都要做一次动作(导航、弹 Snackbar) → 写法 B
写法 B 有一个必须注意的点:它没有生命周期感知。App 退到后台时,这个 collect 还在跑——你可能在用户看不见的时候弹了个 Snackbar,或者导航到了别的页面。下一章就专门讲这件事。
一次十分钟的自查:
1. 搜 `@Composable` 函数体里直接出现的这些词:
.launch( api. repo. Log.
analytics. .add( .remove(
—— 只要不在 Effect 里、不在 onClick 之类的回调里,就是 bug
2. 搜 DisposableEffect
每个 onDispose 里的操作,和块里的操作是不是一一对应?
addXxx 有没有对应的 removeXxx?
3. 搜 SideEffect
里面有没有改 Compose 的 state?有 → 无限重组
4. 搜 rememberCoroutineScope
拿到的 scope 有没有在 body 里被直接 launch?
这一章的一句话
composable 的 body 里不要有副作用。副作用要放进有确定时机的 Effect 里,而选哪个只看两件事:要不要协程、要不要清理。事件驱动的那类,用 rememberCoroutineScope。
下一章:上面提到的「App 退到后台时 collect 还在跑」,代价到底有多大?下一章会量给你看——切到后台 4 秒,白跑 41 次;30 秒,白跑 301 次。而修法只是把一个函数名改长一点。