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

那个在后台不肯停的收集器

这是全书里「改动最小、收益最直接」的一章:把一个函数名改长十几个字符。但要说清楚为什么,得先回答一个很少有人想过的问题——用户按下 Home 键的时候,你的 Composition 怎么了?

collectAsStateWithLifecyclerepeatOnLifecycle后台白跑WhileSubscribed

先回答那个问题

用户按 Home 键,你的 Activity 走 onPauseonStop。那么:

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——退到后台不会销毁。collectAsStateLaunchedEffect 挂在这里
  • Lifecycle——退到后台会掉到 CREATEDcollectAsStateWithLifecyclerepeatOnLifecycle 挂在这里
  • 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 的细节

① 它是挂起函数,会一直挂着。repeatOnLifecycle 直到 Lifecycle 到 DESTROYED 才返回。所以它下面的代码在页面销毁前不会执行——别在它后面写东西。

② 用 STARTED,不是 RESUMEDRESUMED 意味着「有焦点」,而弹出一个对话框、拉下通知栏都会让 Activity 掉到 STARTED。用 RESUMED 会导致这些时候也停掉收集,闪一下。

③ 每次重新进入都是全新的一轮。block 里 remember 不到东西,局部变量会重置——因为它真的是重新 launch 了一次。有状态要跨越前后台,得放在外面。

为什么这个默认值不改

一个合理的疑问:既然 collectAsStateWithLifecycle 几乎总是更好,为什么 collectAsState 还在,而且名字更短?

两个原因:

  • collectAsState 在 Compose 运行时里,不依赖 Android。它在桌面端、iOS 端(Compose Multiplatform)一样能用,而那些平台没有 Android 的 Lifecycle 概念。
  • collectAsStateWithLifecyclelifecycle-runtime-compose,是 Android(现在也支持多平台)生命周期库的一部分。分层上它不能被塞进 Compose 运行时。

所以这不是「哪个更好」,是分层的结果。在 Android App 里,答案基本没有例外:用带 Lifecycle 的那个。

⌗ 到你手上

这一章的动作是全书最简单的一个:

这一章的一句话

Composition 在后台不销毁,所以 collectAsState 会一直收——切后台 4 秒白跑 41 次,30 秒白跑 301 次。修法是 collectAsStateWithLifecycle + WhileSubscribed(5000),两个必须成对。

下一章:卷 V 的最后一块——State 和 Flow 之间的那两扇门derivedStateOf 往一个方向开,snapshotFlow 往另一个方向开。前者有一个很漂亮的数字:滚动 300 次,它重算了 301 次,却只通知了下游 1 次。