【Android】ViewModelScope 与协程生命周期管理:告别内存泄漏,掌控异步边界

ViewModelScope 与协程生命周期管理:告别内存泄漏,掌控异步边界

> 一句话收益 :彻底理解 viewModelScopelifecycleScoperepeatOnLifecycle 的边界差异,写出不泄漏、不崩溃的协程代码。

> 适用版本:Lifecycle 2.4+,Kotlin Coroutines 1.6+,Android 12+

> 阅读时长:约 18 分钟


场景切入:一个隐蔽的内存泄漏

你在 ViewModel 里启动了一个协程请求网络,用户旋转屏幕,旧 Activity 销毁,新 Activity 创建------但那个网络请求还在后台跑,并且持有了旧 Activity 的 Context 引用。LeakCanary 报警了,你盯着日志一脸茫然:协程不是应该自动取消的吗?

问题在于:你用错了 scope


一、协程 Scope 全景:三个核心入口

复制代码
CoroutineScope 体系

├── viewModelScope          // 绑定 ViewModel 生命周期


│   └── 取消时机: ViewModel.onCleared()


├── lifecycleScope          // 绑定 Lifecycle Owner(Activity/Fragment)


│   └── 取消时机: Lifecycle.DESTROYED


└── repeatOnLifecycle       // 在 lifecycleScope 内,按 State 暂停/恢复


└── 暂停时机: 低于指定 State(如 STARTED)

三者定位不同,混用会出问题:

| Scope | 持有者 | 取消时机 | 典型用途 |

|---|---|---|---|

| viewModelScope | ViewModel | onCleared() | 业务逻辑、数据请求 |

| lifecycleScope | Activity/Fragment | onDestroy() | UI 操作、单次任务 |

| repeatOnLifecycle | 依附于 lifecycleScope | 低于指定 State | 持续收集 Flow |


二、viewModelScope 原理深挖

2.1 它从哪里来?

viewModelScopeViewModel 的扩展属性,定义在 androidx.lifecycle:lifecycle-viewmodel-ktx

复制代码
// androidx/lifecycle/ViewModel.kt (简化)

val ViewModel.viewModelScope: CoroutineScope


get() {


val scope: CoroutineScope? = this.getTag(JOB_KEY)


if (scope != null) return scope


return setTagIfAbsent(


JOB_KEY,


CloseableCoroutineScope(


SupervisorJob() + Dispatchers.Main.immediate


)


)


}

关键点:

  • SupervisorJob():子协程失败不会取消兄弟协程

  • Dispatchers.Main.immediate:默认在主线程调度,避免不必要的切换

  • CloseableCoroutineScope :实现了 CloseableViewModel.clear() 时自动调用 close()

2.2 取消链路追踪(AOSP 路径)

复制代码
// 调用链(AOSP Lifecycle 2.4+)

ViewModelStore.clear()


└─ ViewModel.clear()          // androidx/lifecycle/ViewModel.java


└─ ViewModel.onCleared()  // 用户可 override


└─ mBagOfTags.values      // 遍历所有 CloseableCoroutineScope


└─ Closeable.close() // 触发协程取消

AOSP 源码路径:frameworks/support/lifecycle/lifecycle-viewmodel/src/main/java/androidx/lifecycle/ViewModel.java

2.3 何时触发 onCleared?

复制代码
用户按返回键                → Activity.finish() → ViewModelStore.clear() → ✅ 正常取消

旋转屏幕                    → Activity 重建,ViewModel 保留             → ❌ 不取消(正确行为)


进程被系统杀死              → ViewModel 直接消失,协程同样消失         → ✅


Activity 被替换进 BackStack → ViewModel 仍存活                         → ❌ 不取消(注意!)

三、错误写法 → 问题 → 正确写法

案例 1:在 Fragment 中用 lifecycleScope 收集 Flow

复制代码
// ❌ 错误写法:Fragment onStop 后仍然收集,浪费资源甚至 NPE

class MyFragment : Fragment() {


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {


lifecycleScope.launch {


viewModel.uiState.collect { state ->


updateUI(state)  // Fragment 已 STOPPED,updateUI 可能崩溃


}


}


}


}



// ✅ 正确写法:用 repeatOnLifecycle 绑定到 STARTED


class MyFragment : Fragment() {


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {


viewLifecycleOwner.lifecycleScope.launch {


viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {


viewModel.uiState.collect { state ->


updateUI(state)  // 只在 STARTED 到 STOPPED 期间执行


}


}


}


}


}


// 注意:用 viewLifecycleOwner 而不是 this,防止 View 复用时泄漏

案例 2:ViewModel 中直接操作 UI

复制代码
// ❌ 错误写法:ViewModel 持有 Activity 引用

class MyViewModel(private val activity: Activity) : ViewModel() {


fun loadData() {


viewModelScope.launch {


val data = repository.fetch()


activity.updateUI(data)  // 泄漏!旋转屏幕后旧 Activity 无法回收


}


}


}



// ✅ 正确写法:通过 StateFlow/SharedFlow 传递数据


class MyViewModel : ViewModel() {


private val _uiState = MutableStateFlow
   
    (UiState.Loading)
   


val uiState: StateFlow
   
     = _uiState.asStateFlow()
   



fun loadData() {


viewModelScope.launch {


val data = repository.fetch()


_uiState.value = UiState.Success(data)  // 无 UI 引用,安全


}


}


}

案例 3:忽略 SupervisorJob 导致全部子协程取消

复制代码
// ❌ 错误写法:用普通 Job,一个子协程异常取消所有任务

class MyViewModel : ViewModel() {


private val scope = CoroutineScope(Job() + Dispatchers.Main)



fun loadAll() {


scope.launch { loadUserInfo() }   // 如果这个抛异常


scope.launch { loadNewsFeed() }   // 这个也会被取消!


}


}



// ✅ 正确写法:使用 viewModelScope 内置 SupervisorJob


class MyViewModel : ViewModel() {


fun loadAll() {


viewModelScope.launch {


supervisorScope {


launch { loadUserInfo() }   // 失败不影响兄弟协程


launch { loadNewsFeed() }


}


}


}


}

四、repeatOnLifecycle 深度解析

4.1 内部状态机

复制代码
Activity Lifecycle

onCreate  → onStart   → onResume → onPause → onStop  → onDestroy


↓                                 ↑


[STARTED]                         [STOPPED]


协程块 launch                      协程块 cancel


(repeatOnLifecycle 重新启动)       (等待下次 STARTED)

repeatOnLifecycle(Lifecycle.State.STARTED) 的行为:

  1. 每次生命周期进入 STARTED重新启动协程块

  2. 每次生命周期低于 STARTED(进入 CREATED/DESTROYED),取消协程块

  3. 最终在 DESTROYED 时永久取消

4.2 State 选择指南

| State | 含义 | 推荐场景 |

|---|---|---|

| CREATED | Activity 已创建,不可见 | 极少用,几乎不推荐 |

| STARTED | 可见但可能不在前台 | 推荐:收集 UI 状态、展示数据 |

| RESUMED | 完全在前台 | 相机、传感器等需要独占资源 |

4.3 stateIn 与 repeatOnLifecycle 配合

复制代码
// ViewModel 侧:将冷 Flow 转为热 StateFlow

class MyViewModel : ViewModel() {


val userProfile: StateFlow
   
     = repository.getUserFlow()
   


.stateIn(


scope = viewModelScope,


started = SharingStarted.WhileSubscribed(5_000), // 5秒无订阅停止上游


initialValue = null


)


}



// Fragment 侧:用 repeatOnLifecycle 收集


class MyFragment : Fragment() {


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {


viewLifecycleOwner.lifecycleScope.launch {


viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {


viewModel.userProfile.collect { profile ->


profile?.let { renderProfile(it) }


}


}


}


}


}

五、最佳实践

5.1 异常处理:CoroutineExceptionHandler

做法 :用 CoroutineExceptionHandler 统一处理非预期异常,结合 try/catch 处理可预期的业务错误。 原因SupervisorJob 不会传播子协程异常到父级,未捕获的异常会静默丢失,导致 UI 无响应。 对比 :不加 handler,崩溃被 SupervisorJob 吞掉,LogCat 无报错,用户只看到白屏。

复制代码
private val handler = CoroutineExceptionHandler { _, e ->

_errorEvent.value = e.message ?: "未知错误"


}



fun loadData() {


viewModelScope.launch(handler) {


try {


_uiState.value = UiState.Success(repository.fetch())


} catch (e: NetworkException) {


_uiState.value = UiState.Error(e.message)


}


}


}

5.2 取消与超时控制

做法 :给网络请求加 withTimeout,并区分 CancellationException 和业务异常。 原因 :协程取消通过 CancellationException 传播,若在 catch 中捕获了所有异常,会阻止取消传播。 对比catch (e: Exception) 捕获了 CancellationException,导致协程取消失效,ViewModel 销毁后请求依然执行。

复制代码
viewModelScope.launch {

try {


withTimeout(10_000L) {


val data = repository.fetchData()


_uiState.value = UiState.Success(data)


}


} catch (e: TimeoutCancellationException) {


_uiState.value = UiState.Error("请求超时")


} catch (e: CancellationException) {


throw e  // ✅ 必须重新抛出,让协程正常取消


} catch (e: Exception) {


_uiState.value = UiState.Error(e.message ?: "网络错误")


}


}

5.3 stateIn 黄金配置

做法 :用 stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialValue) 将冷流转热流。 原因 :多个 collector 共享同一个上游订阅,旋转屏幕 5 秒内重新订阅不会重启上游数据源。 对比 :不用 stateIn,旋转一次屏幕就重新查数据库,5个 collector 就查5次,性能差且体验不连贯。

复制代码
val userProfile: StateFlow
  
    = repository.getUserFlow()

   
.stateIn(


   
scope = viewModelScope,


   
started = SharingStarted.WhileSubscribed(5_000),


   
initialValue = null


   
)

六、常见坑点

坑 1:launchWhenStarted 的"伪取消"陷阱

  • 现象:切换到后台后,Flow 停止回调,但 LeakCanary 仍报内存泄漏,返回前台数据堆积爆发。

  • 原因launchWhenStarted 只是挂起协程而非取消,协程持有 collector 引用不释放,上游持续发射的数据积压在 Channel 缓冲区。

  • 复现 :使用 lifecycleScope.launchWhenStarted { hotFlow.collect { } } + 频繁锁屏解锁。

  • 解决 :废弃 launchWhenStarted,改用 repeatOnLifecycle(STARTED),后者真正取消并重启协程块。

坑 2:collect 阻塞导致后续代码不执行

  • 现象collect 之后的代码从未执行到,逻辑看似正确但毫无响应。

  • 原因StateFlow.collect 是挂起函数,永不返回,后续代码处于死区。

  • 复现launch { flowA.collect { } ; flowB.collect { } } ------ flowB 永远不会被收集。

  • 解决 :每个 Flow 用独立的 launch 块,或使用 combine/merge 合并多个 Flow。

坑 3:Fragment 中观察者重复注册

  • 现象:页面旋转后,Toast 或导航事件触发了两次。

  • 原因 :每次 Fragment 进出 BackStack,新的 Observer 被添加,但旧的因为用了 this(Fragment 存活)没有取消,导致多次触发。

  • 复现lifecycleScope.launch { sharedFlow.collect { navigate() } }onViewCreated 中调用,但用的是 this 而非 viewLifecycleOwner

  • 解决 :改用 viewLifecycleOwner.lifecycleScope,View 销毁时自动取消订阅。

坑 4:GlobalScope 导致协程无法取消

  • 现象:用户退出 App 后,后台任务还在跑,Battery Historian 显示持续唤醒。

  • 原因GlobalScope 不绑定任何生命周期,APP 进程存活期间协程一直运行,且无法通过 ViewModel/Lifecycle 取消。

  • 复现GlobalScope.launch { infiniteLoop() } 放在 ViewModel 中。

  • 解决 :绝对禁止在 ViewModel 中使用 GlobalScope,一律替换为 viewModelScope


七、总结

  1. viewModelScope 默认首选 :业务逻辑、数据请求都放这里,随 onCleared() 自动取消,天然支持旋转屏幕。

  2. 收集 Flow 必用 repeatOnLifecycle :替代已废弃的 launchWhenStarted,真正取消协程而非挂起。

  3. Fragment 永远用 viewLifecycleOwner:View 生命周期 ≠ Fragment 生命周期,混用必泄漏。

  4. stateIn(WhileSubscribed(5000)) 是旋转黄金配置:兼顾性能和 UX,5 秒窗口容纳旋转重建。

  5. CancellationException 必须重新抛出:捕获所有异常会阻断协程取消链路。

> 核心结论 :协程生命周期管理的本质是"让数据生命周期与 UI 生命周期解耦"------ViewModel 持有数据,repeatOnLifecycle 桥接展示,边界清晰则泄漏无处藏身。


参考资料

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work1 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work1 天前
ch21 签名、校验与发版
kotlin
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui