那个在后台不肯停的收集器
这是全书里「改动最小、收益最直接」的一章:把一个函数名改长十几个字符。但要说清楚为什么,得先回答一个很少有人想过的问题——用户按下 Home 键的时候,你的 Composition 怎么了?
先回答那个问题
用户按 Home 键,你的 Activity 走 onPause → onStop。那么:
Activity 被 stop 了 ✓ Composition 被销毁了吗? 没有 —— 视图层级还在,Composition 还在, 所有 remember 还在,所有 LaunchedEffect 里的协程还在跑。
这是设计使然:用户很可能马上就切回来,重建整个界面太浪费。但它带来一个直接后果——
collectAsState() 挂在 Composition 上,所以它在后台照收不误。
量一下这个「照收不误」有多贵
场景:一个行情页面,每 100 毫秒收到一条价格更新。用户看 1 秒,切出去,过一会儿切回来。
在后台待多久 collectAsState 白跑 collectAsStateWithLifecycle 白跑 1 秒 11 次 0 次 4 秒 41 次 0 次 10 秒 101 次 0 次 30 秒 301 次 0 次
线性增长,因为它压根就没停。每一次「白跑」意味着:
- 一次 Flow 的 emit + 整条操作符链跑一遍
- 一次 state 赋值 → 一次重组(对,界面不可见,但组合照跑)
- 如果链条上有解析、排序、格式化——全都白算
- 如果上游是网络轮询或者传感器——还在真的耗流量、耗电
用户此时正在看别的 App。
修法:一个更长的函数名
// ❌
val state by vm.uiState.collectAsState()
// ✅
val state by vm.uiState.collectAsStateWithLifecycle()
// 需要的依赖
// implementation("androidx.lifecycle:lifecycle-runtime-compose:…")
它做的事:把收集绑到 Lifecycle 上,而不是 Composition 上。默认在 STARTED 以下就取消收集,回到 STARTED 再重新开始。
内部实现就是 repeatOnLifecycle:
// collectAsStateWithLifecycle 大意
LaunchedEffect(flow, lifecycle, minActiveState) {
lifecycle.repeatOnLifecycle(minActiveState) { // ← 进入就 launch,掉出去就 cancel
flow.collect { state.value = it }
}
}
repeatOnLifecycle 这个名字很准确:它会「重复」,每次进入指定状态就把 block 重新 launch 一遍。
代价:上游会被重启
看 demo 表格的最后两列:
上游被重启 冷流被跑了 collectAsState() — 1 遍 collectAsStateWithLifecycle() 2 次(取消 2 次) 2 遍
「白跑 0 次」不是白来的:收集被取消了,回来时要重新 collect 一遍。如果上游是冷流,整段生产逻辑就重跑一次——网络请求重发、数据库重查。
这就是为什么它几乎总是要和第 13 章那个搭配用:
// ViewModel 侧:加一层缓冲,让「短暂离开」不触发上游重启
val uiState = repo.observeTodos()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
// UI 侧:生命周期感知的收集
val state by vm.uiState.collectAsStateWithLifecycle()
两者配合起来的时序是这样的:
用户切出去
t=0 Lifecycle 掉到 CREATED
→ collectAsStateWithLifecycle 取消收集
→ StateFlow 的订阅者数 1 → 0
→ WhileSubscribed 开始倒计时 5 秒
情况 A:2 秒后切回来
t=2000 订阅者 0 → 1,倒计时取消
→ 上游从没停过,StateFlow 里还有最新值
→ 界面立刻显示,没有任何请求
情况 B:10 秒后切回来
t=5000 倒计时到,上游被取消(不再耗电)
t=10000 订阅者回来 → 上游重新 collect → 发一次请求
→ 但 StateFlow 里还留着旧值,界面先显示旧数据,
新数据到了再刷新 —— 不会白屏
这套组合拳的完整效果是:短暂离开零成本,长时间离开省电,回来时不白屏。
三个词,三个不同的生命周期,别搞混:
- Composition——退到后台不会销毁。
collectAsState、LaunchedEffect挂在这里 - Lifecycle——退到后台会掉到
CREATED。collectAsStateWithLifecycle、repeatOnLifecycle挂在这里 - ViewModel——退到后台什么都不会发生。
viewModelScope挂在这里
「App 在后台还在干活」这类 bug,全部是把东西挂错了地方。
那 LaunchedEffect 呢?它也不感知生命周期
对,这是同一个问题的另一半。LaunchedEffect 里的协程在后台照跑。
// ❌ 后台照样每 5 秒刷一次
LaunchedEffect(Unit) {
while (true) { refresh(); delay(5_000) }
}
// ✅ 只在可见时刷
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) { refresh(); delay(5_000) }
}
}
判据:这件事在用户看不见的时候还有意义吗?
| 做的事 | 后台还该做吗 | 怎么写 |
|---|---|---|
| 刷新界面上显示的数据 | 不该 | repeatOnLifecycle(STARTED) |
| 播动画、更新进度条 | 不该 | 同上 |
| 用户点了「上传」,正在上传 | 该 | 放 viewModelScope,或者更该放 WorkManager |
| 正在播放音乐 | 该 | 前台 Service,跟 UI 无关 |
| 埋点上报 | 该 | viewModelScope 或应用级 scope |
第三行值得强调:「用户发起的、不该被中断的操作」不属于 UI 层。它挂在 viewModelScope 上已经比挂在 Composition 上好,但严格说来,用户可能会直接退出这个页面——那 viewModelScope 也会被取消。真正不能丢的操作,答案是 WorkManager。
① 它是挂起函数,会一直挂着。repeatOnLifecycle 直到 Lifecycle 到 DESTROYED 才返回。所以它下面的代码在页面销毁前不会执行——别在它后面写东西。
② 用 STARTED,不是 RESUMED。RESUMED 意味着「有焦点」,而弹出一个对话框、拉下通知栏都会让 Activity 掉到 STARTED。用 RESUMED 会导致这些时候也停掉收集,闪一下。
③ 每次重新进入都是全新的一轮。block 里 remember 不到东西,局部变量会重置——因为它真的是重新 launch 了一次。有状态要跨越前后台,得放在外面。
为什么这个默认值不改
一个合理的疑问:既然 collectAsStateWithLifecycle 几乎总是更好,为什么 collectAsState 还在,而且名字更短?
两个原因:
collectAsState在 Compose 运行时里,不依赖 Android。它在桌面端、iOS 端(Compose Multiplatform)一样能用,而那些平台没有 Android 的 Lifecycle 概念。collectAsStateWithLifecycle在lifecycle-runtime-compose里,是 Android(现在也支持多平台)生命周期库的一部分。分层上它不能被塞进 Compose 运行时。
所以这不是「哪个更好」,是分层的结果。在 Android App 里,答案基本没有例外:用带 Lifecycle 的那个。
这一章的动作是全书最简单的一个:
1. 全项目搜 collectAsState()
几乎每一个都该换成 collectAsStateWithLifecycle()
(加依赖:androidx.lifecycle:lifecycle-runtime-compose)
2. 全项目搜 LaunchedEffect,找里面有 while / delay 循环的
问一句:用户看不见的时候,这件事还该做吗?
不该 → 包一层 repeatOnLifecycle(STARTED)
3. 全项目搜 stateIn( 和 shareIn(
started 参数是 Eagerly / Lazily 的,多半该换成
WhileSubscribed(5_000)
验证:
进入那个页面 → 按 Home → 看 Logcat 还在不在刷
还在刷 —— 你就找到了一个。
《坩埚》第 19 章专门讲了 WhileSubscribed(5000) 的来历、为什么是 5 秒、以及 replayExpiration 那个第二参数是干什么的。
这里只补一句这本书关心的角度:这两个东西必须成对出现。只加 collectAsStateWithLifecycle 而 ViewModel 那边是 Eagerly,你省的只是 UI 那一小半;只加 WhileSubscribed 而 UI 那边还是 collectAsState,订阅者根本没归过零,那个 5000 一次都不会生效。
这一章的一句话
Composition 在后台不销毁,所以 collectAsState 会一直收——切后台 4 秒白跑 41 次,30 秒白跑 301 次。修法是 collectAsStateWithLifecycle + WhileSubscribed(5000),两个必须成对。
下一章:卷 V 的最后一块——State 和 Flow 之间的那两扇门。derivedStateOf 往一个方向开,snapshotFlow 往另一个方向开。前者有一个很漂亮的数字:滚动 300 次,它重算了 301 次,却只通知了下游 1 次。