最近接了一个老项目,线上崩溃报表里躺着一堆IllegalStateException: RecyclerView is destroyed和JobCancellationException引发的奇怪问题。查了一圈定位到同一条根因:页面都用GlobalScope或者干脆裸写thread {}做异步,Activity 销毁之后协程还在加班,跑到一半回头去操作已经被回收的 View。那段时间我把项目里所有协程调用全部梳理了一遍,最终都收敛到lifecycleScope上。这玩意儿不是什么黑魔法,就是 Kotlin 协程官方对"生命周期与异步任务冲突"给出的标准答案。这篇不打算从概念 wiki 抄过来,我把底层原理、选型逻辑、实战姿势和踩坑记录放在一起讲,希望能帮还在纠结"协程作用域到底怎么用"的开发者一次理清楚。
1. 协程作用域:Kotlin协程体系的"地基"与"安全带"
1.1 没有作用域的协程是什么样
先看一个最典型的反面案例:
GlobalScope.launch { val data = api.fetchData() textView.text = data }这段代码在大多数教程里都被写过,但放到真实项目里就是事故现场。GlobalScope的生命周期跟着整个进程走,Activity 销毁了它不销毁,textView却已经被回收。轻则灰色 ANR 元凶 + 内存泄漏,重则直接崩溃。更隐蔽的问题是:当页面快速销毁再重建,多个GlobalScope任务叠加在一起,网络回调乱序返回,UI 上出现短暂错乱。所以协程不是"线程 + 很方便的 API",它是一门有纪律的并发框架,而纪律的第一条就是:每个协程都必须属于某个作用域,作用域决定了协程能活多久。
1.2 作用域的本质:结构化并发的承诺
CoroutineScope是一个极其简单的接口,只有一个成员coroutineContext。真正撑起整套体系的是Job和它的父子关系。
- 父 Job 取消,所有子 Job 跟着取消;
- 父 Job 等待所有子 Job 完成,自己才算完成;
- 子 Job 出现异常,会沿着结构向上传播,影响兄弟任务。
这套机制叫结构化并发(Structured Concurrency),可以把它理解成一支施工队:工头(父 Job)接活之后会把任务分给工人(子 Job),如果工头被撤了,工人手里的活全部停掉;如果工人没干完,工头不能宣布收工。Kotlin 协程的Scope.launch就是"工头分活"的动作,而lifecycleScope是一个自带"撤工头"信号的工头——当 Lifecycle 走到 DESTROYED,它自动把整支队伍解散。
1.3 launch与async为什么要挂在作用域上
launch和async是协程的两大启动方式,它们都不是顶层函数,必须挂在某个CoroutineScope上调用。原因是编译器需要从调用处的coroutineContext里取到Job,把这个新协程挂到既有结构上:
scope.launch { // 这里的 this 是 CoroutineScope // coroutineContext[Job] 就是父 Job }如果你脱离了作用域直接调用launch,编译器会直接报错。这个"强制挂靠"的设计很聪明:它让开发者无法轻易写出野协程。真正聪明的用法不是"我开心就 new 一个 scope",而是让作用域的边界匹配业务逻辑的边界:页面的异步逻辑跟页面同生共死,ViewModel 的异步逻辑跟 ViewModel 同生共死。lifecycleScope就是专门把"页面边界"翻译成"协程边界"的桥。
2. lifecycleScope的底层实现:生命周期感知不是魔法
2.1 lifecycleScope是哪来的:一行扩展属性背后的类
很多同学熟悉lifecycleScope这个名字,但不知道它的真身。在lifecycle-runtime-ktx中,LifecycleOwner有一个扩展属性:
val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope get() = lifecycle.coroutineScope而Lifecycle.coroutineScope内部通过原子引用缓存了一个LifecycleCoroutineScopeImpl:
private val Lifecycle.coroutineScope: LifecycleCoroutineScope get() { while (true) { val existing = mInternalScopeRef.get() if (existing != null) return existing val newScope = LifecycleCoroutineScopeImpl( this, SupervisorJob() + Dispatchers.Main.immediate ) if (mInternalScopeRef.compareAndSet(null, newScope)) { return newScope } } }这里是双重检查锁 + CAS 的变体,核心目标只有一个:同一个 Lifecycle 对应唯一一个协程作用域,不管你在 Activity、Fragment 还是自定义LifecycleOwner里取多少次lifecycleScope,拿到的都是同一个对象。否则每次访问都创建新 scope,生命周期取消就完全对不上了。
2.2 SupervisorJob + Main.immediate:默认参数里藏着大学问
SupervisorJob() + Dispatchers.Main.immediate这两个默认参数绝不是随便选的。
先看SupervisorJob。普通Job的子协程一旦抛出未捕获异常,整个作用域就会炸掉,兄弟任务全被取消。但页面里的异步任务经常有独立失败的需求:一个网络请求 404 不能把另一个正在进行的数据库查询也干掉。SupervisorJob的语义是"每个子任务独立失败,不影响兄弟和父级",这对 UI 场景非常合适。
再看Dispatchers.Main.immediate。Main意味着所有launch默认回到主线程,这本就是 UI 开发的诉求。immediate是优化点:如果当前已经在主线程,Dispatchers.Main也可能会把任务重新排队一次,造成额外的一帧延迟;Dispatchers.Main.immediate则会检查当前线程,如果已经在主线程就直接执行,避免了无意义的调度开销。在打开页面的瞬间,lifecycleScope.launch能立刻跑起来,这一点对首帧体验有实际帮助。
所以lifecycleScope的默认上下文等于告诉你:这是一个"主线程优先、单点故障隔离、自动跟生命周期走"的作用域。
2.3 取消链路是怎样挂到Lifecycle上的
LifecycleCoroutineScopeImpl实现了LifecycleEventObserver接口,把自己注册成 Lifecycle 的观察者。源码核心逻辑大致如下:
internal class LifecycleCoroutineScopeImpl( override val lifecycle: Lifecycle, override val coroutineContext: CoroutineContext ) : LifecycleCoroutineScope(), LifecycleEventObserver { internal fun register() { launch { if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) { lifecycle.addObserver(this@LifecycleCoroutineScopeImpl) } } } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { if (lifecycle.currentState <= Lifecycle.State.CREATED) { // 状态回退到 CREATED 以下时,处理一些内部策略 } if (lifecycle.currentState == Lifecycle.State.DESTROYED) { coroutineContext.cancel() } } }当 Activity 的onDestroy之后Lifecycle状态推进到DESTROYED,观察者收到事件,调用coroutineContext.cancel()。这一步会取消SupervisorJob,于是所有挂在这个 scope 下的子协程全部收到取消信号,并且协程内部会等待挂起函数对取消作出响应。这也解释了为什么lifecycleScope里的协程不会立刻"消失"——它有一个协作式取消的过程:正在执行的代码会跑到下一个挂起点,检查取消标志,抛出CancellationException,才真正结束。
关键在于:这套机制对使用者完全透明。你不需要手动去override onDestroy再 cancel,也不用担心忘写导致泄漏。
3. 作用域选型博弈:GlobalScope、lifecycleScope、viewModelScope怎么选
3.1 GlobalScope为何人人喊打
提到GlobalScope,很多人只知道"会泄漏",但不清楚它到底违背了什么。GlobalScope实际上是一个单例作用域,它的Job没有父节点,运行时也只持有非常弱的结构。GlobalScope.launch的协程从启动那一刻起就游离于任何业务组件之外。
它的问题不仅仅是内存泄漏,而是业务边界完全消失。你无法从页面维度合理地取消它,也无法保证多个任务之间的依赖顺序。大型项目里GlobalScope代码越多,后期排查越痛苦,经常出现"明明页面关了,日志里还在打印这个页面的网络请求"这种诡异现象。
谷歌在 Android 官方文档里反复强调不要使用GlobalScope,Kotlin 协程作者的《Kotlin Coroutines》里也给出过明确建议:GlobalScope只适合极少数需要"App 进程级"生命周期的场景,比如统计 SDK 初始化、无 UI 的长期后台任务。但即便如此,也应该用自己的CoroutineScope显式管理,而不是直接裸用GlobalScope。
3.2 lifecycleScope vs viewModelScope:别再把页面逻辑堆在ViewModel里
这两者是 Android 项目里最容易混淆的兄弟。viewModelScope来自androidx.lifecycle:lifecycle-viewmodel-ktx,跟随ViewModel的onCleared取消;lifecycleScope跟随LifecycleOwner(Activity/Fragment)的DESTROYED取消。它们没有绝对的优劣之分,选型的核心依据是任务到底属于谁。
对 UI 状态收集、动画、传感器监听这种强依赖页面存在的任务,用lifecycleScope。对需要跨越配置变更(旋转屏幕、切夜间模式导致 Activity 重建)的仓库层数据加载,用viewModelScope。配置变更时 Activity 会销毁重建,此时lifecycleScope里所有没跑完的协程直接取消,但数据加载如果已经在viewModelScope里,就不会被中断,新页面还能继续拿到结果。所以很多 MVVM 架构会做出这样的分工:
| 任务类型 | 推荐作用域 | 理由 |
|---|---|---|
| UI 刷新、动画、传感器采集 | lifecycleScope | 页面没了必须立即停 |
| 页面级网络请求 + 状态更新 | viewModelScope | 旋转屏时不停,结果直接进 StateFlow |
| 必须等 ViewModel 清空后才停的任务 | viewModelScope | 业务数据和 UI 生命周期解耦 |
| 进程级、无 UI 后台任务 | 自定义 Scope / 应用级 Scope | 显式管理,进程销毁才停 |
有人会疑惑:"那我是不是应该把所有逻辑都塞进viewModelScope?"千万别。viewModelScope的任务在页面销毁后仍会继续,如果里面的代码持有了 Activity / View 的强引用,照样泄漏。更合理的分层是:页面只管页面的事,ViewModel 管业务和数据的生命周期,两者的 scope 各司其职。
3.3 自定义CoroutineScope:什么时候轮到自己动手
除开lifecycleScope和viewModelScope,项目里还存在需要自定义CoroutineScope的场景,例如一个需要管理多个子任务生命周期的工作管理器,或者一个不知道 Android 组件存在的纯 Kotlin 模块。这时候我会写这样的模板:
class MyRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun doLongTask() { scope.launch { /* ... */ } } fun destroy() { scope.cancel() } }重点在于destroy()方法必须能在确定的时间点被调用,由使用者负责。很多自定义 scope 的泄漏,就是因为"理论上要调 destroy,实际上没人记得调"。因此我的建议是:如果没有非常明确的管理者,就不要自定义 scope,直接复用框架提供的lifecycleScope/viewModelScope,这是普通人最容易做出正确决定的方式。
4. lifecycleScope的实战姿势:从网络请求到Flow收集
4.1 常规用法:launch、async、withContext组合
lifecycleScope最基本的用法就是替代之前的thread、Handler.post、AsyncTask。下面是一个比较标准的组合:
lifecycleScope.launch { // 主线程启动,可以做 UI 操作 showLoading() val result = withContext(Dispatchers.IO) { repository.fetchData() } render(result) hideLoading() }withContext在这里负责切换线程,但它不会改变协程属于lifecycleScope的事实——页面销毁时,这个协程同样会被取消。要注意withContext不是一个新的作用域,它只是挂起并切换上下文,代码逻辑还是原来的协程。
如果两条网络请求没有依赖关系,可以用async并行:
lifecycleScope.launch { val one = async(Dispatchers.IO) { repository.fetchUserInfo() } val two = async(Dispatchers.IO) { repository.fetchFriends() } val user = one.await() val friends = two.await() updateUI(user, friends) }有依赖关系的请求就串行执行,避免用 async 包一层再写 $_1.await();$_2.await() 这种绕弯的写法。
4.2 Flow收集的正解:repeatOnLifecycle
在lifecycle-runtime-ktx:2.4.0之前,从 Flow 收集数据的推荐姿势是launchWhenStarted { flow.collect {} }。但这个 API 存在一个严重的语义问题(下一章会细讲),2.4.0 之后官方给出了新的标准写法,lifecycle-runtime-ktx:2.6.0里launchWhenXxx已经标记为 deprecated:
class MyFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> render(state) } } } } }这段代码的关键点在于:repeatOnLifecycle(STARTED)会在生命周期进入STARTED时启动一个新的协程去执行里面的代码块,在生命周期降到STARTED以下时取消这个协程,再次回到STARTED时再重新启动。于是冷流每次都会重新订阅,热流也能保证只在可见期间收集。这样做的收益不仅是省资源,更重要的是避免了后台状态更新污染 UI。
如果你需要让某个数据在RESUMED才可见期间收集,比如相机预览相关逻辑,就把状态改成Lifecycle.State.RESUMED。之后再配合collectLatest处理高频率数据的竞态问题,才是完整的姿势。
4.3 细粒度生命周期函数:whenCreated、whenStarted、whenResumed
如果只是想"在某个生命周期阶段做一次性的事情",可以使用LifecycleOwner的扩展方法:
lifecycleScope.launch { lifecycle.whenCreated { // 首次调用时执行 } lifecycle.whenStarted { // 首次进入 STARTED 时执行 } lifecycle.whenResumed { // 首次进入 RESUMED 时执行 } }whenCreated这类函数的语义是:如果当前生命周期状态已经达到/超过目标状态,立即执行;否则挂起等待,直到生命周期达到目标状态。举个例子,在 fragment 里用viewLifecycleOwner.lifecycleScope.launch { lifecycle.whenStarted { initCamera() } },就算代码执行时机比onStart早,它也会等进入STARTED后再调initCamera(),非常安全。
不过要注意,whenXxx在生命周期低于目标状态时只是暂停协程,并不取消。协程仍然存活,所以如果你把whenStarted { flow.collect {} }写在里面,依然会踩到launchWhenStarted的那批坑。一次性任务用它很顺手,长期收集的任务请一律移交给repeatOnLifecycle。
5. 那些年我踩过的lifecycleScope坑
5.1 坑一:launchWhenStarted收集Flow,活了又死
这是我在老代码里最常见到的写法:
lifecycleScope.launchWhenStarted { viewModel.uiEvent.collect { event -> handle(event) } }表面看"只在 STARTED 之后收集,停到后台就暂停",实际上launchWhenStarted的实现是:进入STARTED时启动协程,低于STARTED时挂起协程。变量不会丢,但状态已经可能过期了。问题在于:从后台回到前台时,协程基于旧状态继续收集,中间错过的事件永远不会补发,某些热流还会因为上游没有重新发射而显示过时数据。
repeatOnLifecycle则完全不同——它每次从STARTED重新启动一个全新的收集过程,冷流会重新执行生产代码,从而拿到最新的初始状态。这就是为什么官方把launchWhenStarted标为废弃。我在迁移过程中踩到的具体现象是:"切到别的 App 再切回来,列表内容空白,要下拉刷新才有数据",原因就是这个。
5.2 坑二:调用cancelChildren()后作用域变一锤子买卖
有个任务只做了一半需要取消,求快的人会这样写:
lifecycleScope.lifecycle.coroutineContext[Job]?.cancelChildren()cancelChildren()确实取消了当前 lifecycleScope 的所有子任务。问题是:如果代码里某个子任务正好在下一次执行前才isActive检查,而页面还在STARTED状态,那之后你在这个 scope 下发起的launch还能不能跑?答案是能跑,但前提是你取消了children而不是 scope 本身。真正危险的是类似lifecycleScope.coroutineContext.cancel()的写法——它直接把SupervisorJob置为Cancelling,从此这个lifecycleScope就是废的,后续所有launch都会直接抛出JobCancellationException。
更麻烦的是,这个坑一般不会当场报错,而是"这个页面的异步行为时好时坏"。排查思路是检查有没有代码直接对lifecycleScope.coroutineContext下手,或者把外层 Job 被 cancel 的情况记录下来。正确做法是维护一个专门的Job子任务句柄,只取消自己需要取消的部分:
private var loadJob: Job? = null fun reload() { loadJob?.cancel() loadJob = lifecycleScope.launch { // ... } }5.3 坑三:在Fragment里拿到了Activity的scope
Activity 和 Fragment 都有lifecycleScope,但为什么用错的地方比比皆是?最典型的场景是 Fragment 里想用某个 Activity 提供的结果,图省事直接写:
requireActivity().lifecycleScope.launch { // 拿到 Activity 的 scope }如果 Fragment 已经 pop 销毁,但 Activity 还没销毁,这个协程就会继续跑。你要是顺手在里面更新 Fragment 的 View,直接崩。Fragment 的视图和生命周期有更细的维度:Fragment 实例还在,但它的view可能在onDestroyView后被回收。这时候应该使用viewLifecycleOwner.lifecycleScope来处理所有与 Fragment 视图相关的协程。
一个小工具习惯:Fragment 里所有 UI 相关协程都挂在viewLifecycleOwner.lifecycleScope上,这样在视图销毁时协程立刻取消,不会出现"Fragment 已经没了,协程还在跑"的情况。Fragment 层面的纯数据逻辑再考虑viewLifecycleOwner或 Fragment 自身的生命周期,按职责区分。
5.4 collectLatest与collect的选择纠结
在repeatOnLifecycle里收集 Flow,很多人会犯选择困难症。简单区分:
collect:顺序处理每个值,处理慢时上游需要排队。collectLatest:新值到来时,如果正在处理旧值,取消旧值的处理块,直接处理最新值。conflate:只保留最新值,但处理过程不中断。
对于 UI 数据,如果渲染逻辑很快且不能丢中间状态,用collect;如果上游事件频率远高于处理能力且只关心最新结果(搜索联想、滑块进度),用collectLatest。一些人在collectLatest里做了不可取消的操作(比如数据库写入,且开着NonCancellable),那取消旧块机制就形同虚设,反而容易引发并发写冲突,这点要特别留意。
6. 生命周期结束后的善后问题:数据、测试与性能
6.1 善后一:页面销毁后任务还在“偷偷”跑
lifecycleScope的确会在DESTROYED时取消协程,但协程取消不等于代码立刻停止。如果你在协程里使用了withContext(NonCancellable),或者手动捕获了CancellationException并继续执行,任务就会绕过取消机制。最典型的例子:
lifecycleScope.launch { try { repository.saveData() } catch (e: CancellationException) { // 忽略取消,继续执行? // 千万别这样写,除非你有非常明确的理由 } }正确做法是:要么不补获取消异常,要么在捕获后重新抛出throw e。否则协程的取消语义会被破坏,造成"页面销毁后任务还在努力工作"的假象。
另一个被许多人忽略的点是onCleared()里再去viewModelScope.launch。ViewModel 已经清除,但如果你手动持有 scope 并且它还没取消,新任务会跑在一个孤立的上下文里。最稳的收尾方式是只负责取消,不负责启动新任务。
6.2 善后二:单元测试里怎么伺候lifecycleScope
单元测试时不能直接依赖真实组件。推荐做法是用MainDispatcherRule把主线程替换成StandardTestDispatcher/UnconfinedTestDispatcher,再配合lifecycleScope的控制器推进生命周期状态。如果你用的是 AndroidX Test 的LifecycleOwner测试构造器,可以手动发ON_CREATE、ON_START、ON_DESTROY事件。核心测试思路是把"生命周期状态变化"变成测试可控的事件,而不是真实等待 Activity 销毁。
class MyViewModelWithLifecycleTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private lateinit var lifecycle: LifecycleRegistry private lateinit var owner: LifecycleOwner @Before fun setup() { lifecycle = LifecycleRegistry(mockLifecycleOwner) // ... } @Test fun `when lifecycle destroyed - coroutine should cancel`() = runTest { var completed = false lifecycleOwner.lifecycleScope.launch { try { delay(10_000) } finally { completed = true } } lifecycle.currentState = Lifecycle.State.DESTROYED advanceUntilIdle() assertTrue(completed) } }runTest加advanceUntilIdle是协程测试的黄金组合,但要注意Dispatchers.Main.immediate在测试环境里必须被替换干净,否则会报Module with the Main dispatcher had failed to initialize。
6.3 善后三:性能层面,别让生命周期Scope无谓堆积
lifecycleScope本身不会造成泄漏,但如果你在onCreate里不断launch并且任务都没跑完,在DESTROYED时它们会一次性全部取消。取消一堆协程也有一定开销——每个协程都要走协作式取消逻辑,释放挂起点。如果发现onDestroy阶段的 CPU 占用偏高,可以审视是否在页面里开了太多并行协程,是否能合并成一条串行链路。
另一个性能细节是:lifecycleScope默认使用Dispatchers.Main,如果你在里面跑重度计算,即使只有一个任务也会卡主线程。正确做法是计算部分放进withContext(Dispatchers.Default),UI 更新回到主线程。判断标准很简单:凡是超过几十毫秒的 CPU 密集操作,都不应该直接躺在lifecycleScope.launch主线程代码块里。
我在实际项目里的体会是:lifecycleScope用顺了之后,你再看那些"协程到底会不会泄漏"的争论会变得很清晰——它只是一个工具,工具本身不泄漏,乱用才泄漏。把作用域的边界和业务组件的生命周期对齐,大部分异步问题都能在架构层面消解掉。最后再分享一个小技巧:如果你在写自定义LifecycleOwner(比如自定义的带生命周期的组件),记得一定要把lifecycle的currentState推进到DESTROYED,否则lifecycleScope永远不会被取消,这大概是最容易被忽略的一处暗坑。