【Android】ViewModelScope 与协程生命周期管理:告别内存泄漏,掌控异步边界 ViewModelScope 与协程生命周期管理告别内存泄漏掌控异步边界一句话收益彻底理解viewModelScope、lifecycleScope、repeatOnLifecycle的边界差异写出不泄漏、不崩溃的协程代码。适用版本Lifecycle 2.4Kotlin Coroutines 1.6Android 12阅读时长约 18 分钟---场景切入一个隐蔽的内存泄漏你在 ViewModel 里启动了一个协程请求网络用户旋转屏幕旧 Activity 销毁新 Activity 创建——但那个网络请求还在后台跑并且持有了旧 Activity 的 Context 引用。LeakCanary 报警了你盯着日志一脸茫然协程不是应该自动取消的吗问题在于你用错了 scope。---一、协程 Scope 全景三个核心入口CoroutineScope 体系├── viewModelScope // 绑定 ViewModel 生命周期│ └── 取消时机: ViewModel.onCleared()├── lifecycleScope // 绑定 Lifecycle OwnerActivity/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: CoroutineScopeget() {val scope: CoroutineScope? this.getTag(JOB_KEY)if (scope ! null) return scopereturn setTagIfAbsent(JOB_KEY,CloseableCoroutineScope(SupervisorJob() Dispatchers.Main.immediate))}关键点-SupervisorJob()子协程失败不会取消兄弟协程-Dispatchers.Main.immediate默认在主线程调度避免不必要的切换-CloseableCoroutineScope实现了CloseableViewModel.clear()时自动调用close()2.2 取消链路追踪AOSP 路径// 调用链AOSP Lifecycle 2.4ViewModelStore.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.java2.3 何时触发 onCleared用户按返回键 → Activity.finish() → ViewModelStore.clear() → ✅ 正常取消旋转屏幕 → Activity 重建ViewModel 保留 → ❌ 不取消正确行为进程被系统杀死 → ViewModel 直接消失协程同样消失 → ✅Activity 被替换进 BackStack → ViewModel 仍存活 → ❌ 不取消注意---三、错误写法 → 问题 → 正确写法案例 1在 Fragment 中用 lifecycleScope 收集 Flow// ❌ 错误写法Fragment onStop 后仍然收集浪费资源甚至 NPEclass MyFragment : Fragment() {override fun onViewCreated(view: View, savedInstanceState: Bundle?) {lifecycleScope.launch {viewModel.uiState.collect { state -updateUI(state) // Fragment 已 STOPPEDupdateUI 可能崩溃}}}}// ✅ 正确写法用 repeatOnLifecycle 绑定到 STARTEDclass 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 复用时泄漏案例 2ViewModel 中直接操作 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 内置 SupervisorJobclass MyViewModel : ViewModel() {fun loadAll() {viewModelScope.launch {supervisorScope {launch { loadUserInfo() } // 失败不影响兄弟协程launch { loadNewsFeed() }}}}}---四、repeatOnLifecycle 深度解析4.1 内部状态机Activity LifecycleonCreate → 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 转为热 StateFlowclass 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)---六、常见坑点坑 1launchWhenStarted的伪取消陷阱-现象切换到后台后Flow 停止回调但 LeakCanary 仍报内存泄漏返回前台数据堆积爆发。-原因launchWhenStarted只是挂起协程而非取消协程持有 collector 引用不释放上游持续发射的数据积压在 Channel 缓冲区。-复现使用lifecycleScope.launchWhenStarted { hotFlow.collect { } } 频繁锁屏解锁。-解决废弃launchWhenStarted改用repeatOnLifecycle(STARTED)后者真正取消并重启协程块。坑 2collect阻塞导致后续代码不执行-现象collect之后的代码从未执行到逻辑看似正确但毫无响应。-原因StateFlow.collect是挂起函数永不返回后续代码处于死区。-复现launch { flowA.collect { } ; flowB.collect { } }——flowB永远不会被收集。-解决每个 Flow 用独立的launch块或使用combine/merge合并多个 Flow。坑 3Fragment 中观察者重复注册-现象页面旋转后Toast 或导航事件触发了两次。-原因每次 Fragment 进出 BackStack新的 Observer 被添加但旧的因为用了thisFragment 存活没有取消导致多次触发。-复现lifecycleScope.launch { sharedFlow.collect { navigate() } }在onViewCreated中调用但用的是this而非viewLifecycleOwner。-解决改用viewLifecycleOwner.lifecycleScopeView 销毁时自动取消订阅。坑 4GlobalScope导致协程无法取消-现象用户退出 App 后后台任务还在跑Battery Historian 显示持续唤醒。-原因GlobalScope不绑定任何生命周期APP 进程存活期间协程一直运行且无法通过 ViewModel/Lifecycle 取消。-复现GlobalScope.launch { infiniteLoop() }放在 ViewModel 中。-解决绝对禁止在 ViewModel 中使用GlobalScope一律替换为viewModelScope。---七、总结1.viewModelScope默认首选业务逻辑、数据请求都放这里随onCleared()自动取消天然支持旋转屏幕。2.收集 Flow 必用repeatOnLifecycle替代已废弃的launchWhenStarted真正取消协程而非挂起。3.Fragment 永远用viewLifecycleOwnerView 生命周期 ≠ Fragment 生命周期混用必泄漏。4.stateIn(WhileSubscribed(5000))是旋转黄金配置兼顾性能和 UX5 秒窗口容纳旋转重建。5.CancellationException必须重新抛出捕获所有异常会阻断协程取消链路。核心结论协程生命周期管理的本质是让数据生命周期与 UI 生命周期解耦——ViewModel 持有数据repeatOnLifecycle桥接展示边界清晰则泄漏无处藏身。---参考资料- 官方文档Use Kotlin coroutines with lifecycle-aware components- 官方文档repeatOnLifecycle API design story- 官方文档StateFlow and SharedFlow- 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