【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 桥接展示,边界清晰则泄漏无处藏身。


参考资料

相关推荐
私人珍藏库2 小时前
【Android】瞬净ins版-无水印解析-无水印视频保存
android·app·工具·软件·多功能
Maxwellhang2 小时前
Termux 安装 Claude Code + 配置 DeepSeek API
android·智能手机
百度搜知知学社2 小时前
一键装裱照片,相框APP内置滤镜与贴纸编辑器
android·编辑器·滤镜·图片编辑·贴纸·相框
AFinalStone3 小时前
Android12 U盘插拔链路源码全解析(四):Framework层(上) —— UsbHostManager
android·frameworks
qq3621967053 小时前
第三方安卓应用商店安全评测 2026:Appteka、Aptoide、APKPure 等 7 家横评
android·网络·人工智能·安全·chatgpt·智能手机
coderhuo4 小时前
JibarOS 简介:Android AICore 开源实现方案
android·ai编程
故渊at4 小时前
第十五板块:Android 系统调试与逆向工程 | 第三十六篇:Smali 字节码语义与 Dalvik 指令集
android·指令集·dalvik·smali·字节码语义
J2虾虾4 小时前
Android支持Java语言的标准
android·java·开发语言
charlee444 小时前
Unity在安卓端如何调试输出信息
android·unity·adb·游戏引擎·真机调试