ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

lifecycleScope协程作用域实战:解决Android生命周期与异步任务冲突

lifecycleScope协程作用域实战:解决Android生命周期与异步任务冲突 最近接了一个老项目线上崩溃报表里躺着一堆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内部通过原子引用缓存了一个LifecycleCoroutineScopeImplprivate 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(thisLifecycleCoroutineScopeImpl) } } } 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 官方文档里反复强调不要使用GlobalScopeKotlin 协程作者的《Kotlin Coroutines》里也给出过明确建议GlobalScope只适合极少数需要App 进程级生命周期的场景比如统计 SDK 初始化、无 UI 的长期后台任务。但即便如此也应该用自己的CoroutineScope显式管理而不是直接裸用GlobalScope。3.2 lifecycleScope vs viewModelScope别再把页面逻辑堆在ViewModel里这两者是 Android 项目里最容易混淆的兄弟。viewModelScope来自androidx.lifecycle:lifecycle-viewmodel-ktx跟随ViewModel的onCleared取消lifecycleScope跟随LifecycleOwnerActivity/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已经标记为 deprecatedclass 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的scopeActivity 和 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永远不会被取消这大概是最容易被忽略的一处暗坑。
返回列表