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

> 一句话收益 :彻底理解 viewModelScope、lifecycleScope、repeatOnLifecycle 的边界差异,写出不泄漏、不崩溃的协程代码。
> 适用版本: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 它从哪里来?
viewModelScope 是 ViewModel 的扩展属性,定义在 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:实现了Closeable,ViewModel.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) 的行为:
-
每次生命周期进入
STARTED,重新启动协程块 -
每次生命周期低于
STARTED(进入CREATED/DESTROYED),取消协程块 -
最终在
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。
七、总结
-
viewModelScope默认首选 :业务逻辑、数据请求都放这里,随onCleared()自动取消,天然支持旋转屏幕。 -
收集 Flow 必用
repeatOnLifecycle:替代已废弃的launchWhenStarted,真正取消协程而非挂起。 -
Fragment 永远用
viewLifecycleOwner:View 生命周期 ≠ Fragment 生命周期,混用必泄漏。 -
stateIn(WhileSubscribed(5000))是旋转黄金配置:兼顾性能和 UX,5 秒窗口容纳旋转重建。 -
CancellationException必须重新抛出:捕获所有异常会阻断协程取消链路。
> 核心结论 :协程生命周期管理的本质是"让数据生命周期与 UI 生命周期解耦"------ViewModel 持有数据,repeatOnLifecycle 桥接展示,边界清晰则泄漏无处藏身。
参考资料
-
AOSP 源码:
frameworks/support/lifecycle/lifecycle-viewmodel-ktx/src/main/java/androidx/lifecycle/ViewModel.kt -
AOSP 源码:
frameworks/support/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/RepeatOnLifecycle.kt -
AOSP 源码:
frameworks/support/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/FlowExt.kt