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

五个 Effect,各管一段

Effect API 看起来有五六个,记起来很烦。但它们的分工其实只有两条线,画一个二乘二的格子就装得下——而且那条最重要的规矩根本不在这五个里面,它是一句「不要」。

DisposableEffectSideEffectproduceStaterememberCoroutineScope

先说那句「不要」

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
  • produceStateawaitDispose

③ 用 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,或者导航到了别的页面。下一章就专门讲这件事。

⌗ 到你手上

这一章的一句话

composable 的 body 里不要有副作用。副作用要放进有确定时机的 Effect 里,而选哪个只看两件事:要不要协程、要不要清理。事件驱动的那类,用 rememberCoroutineScope

下一章:上面提到的「App 退到后台时 collect 还在跑」,代价到底有多大?下一章会量给你看——切到后台 4 秒,白跑 41 次;30 秒,白跑 301 次。而修法只是把一个函数名改长一点。