如何正确取消 ViewModel 里的协程

850 阅读7分钟

0.png

昨天,我写过这样一篇文章,里面讲述了一个可能 90% 的开发者都答不上来问题。

中午我和同事吃饭的时候,他问我:然后呢,你就水一篇文章不给解决方案?你知道程序员流行一句什么样的话吗?——留图不留种,xx万人x。

OK,打住!

这里,我们从 viewModelScope 入手,看看这个取消的问题!

viewModelScope

我知道,作为一名安卓开发者,都曾花费无数时间调试各种诡异的崩溃问题和内存泄漏,而这些问题的根源往往都指向协程处理不当。

如果使用 Kotlin 协程,大概率对 viewModelScope 并不陌生。

它是将协程与 ViewModel 生命周期绑定的绝佳工具,但如果取消操作处理不当,麻烦便会接踵而至。

泄漏的协程会持续占用资源、浪费 CPU 算力,更糟的是,还可能导致应用行为异常。

在本文中,我将分享自己摸索出的 viewModelScope 取消操作实战经验,结合具体示例和技巧,避免被“xx万人x”。

为何取消操作至关重要

viewModelScope 是与 ViewModel 生命周期绑定的协程作用域。

ViewModel 被销毁时(比如关联的 ActivityFragment 销毁),该作用域会自动取消所有关联的协程。

听起来很完美?

但关键问题在于:自动取消不代表可以高枕无忧。稍不注意,协程仍可能超时运行、占用资源,或触发未捕获异常导致应用崩溃。

还记得之前的文章的那句话吗?协程的取消是协作的。

规范的取消操作能确保应用始终轻量、行为可预测,尤其是在配置变更或进程终止的场景下。

接下来,我们聊聊如何专业地管理 viewModelScope 的取消逻辑。

理解 viewModelScope

viewModelScopeandroidx.lifecycle:lifecycle-viewmodel-ktx 库提供的属性,本质是绑定到 Dispatchers.Main 主线程、且与 ViewModel 生命周期关联的 CoroutineScope

ViewModelonCleared() 方法被调用时,该作用域会取消所有协程。

这对 Android 开发而言堪称绝配——毕竟 UI 组件的生命周期本就多变,但这一切的前提是,你编写的代码要具备“取消感知”能力。

以下是使用 viewModelScope 的简单 ViewModel 示例:

class MyViewModel : ViewModel() {
    fun fetchData() {
        viewModelScope.launch {
            val data = fetchDataFromNetwork() // 挂起函数
            // 更新UI
        }
    }
}

ViewModel 被销毁(比如用户跳转页面),协程会被取消。

但如果 fetchDataFromNetwork() 是耗时操作,情况会如何?

取消操作不当的常见陷阱

在我早期的项目中,我曾以为 viewModelScope 能“魔法般”处理所有问题,实际上根本不可能。

以下是我踩过的典型坑:

  1. 不可取消的操作:部分挂起函数(如第三方库调用)不响应取消指令,导致资源持续占用。或者,你调用的函数就是一个普通函数,根本不是挂起函数,不可能响应取消指令;
  2. 资源泄漏:若协程持有 ContextView 的引用,即便取消协程也无法释放资源,最终引发内存泄漏;
  3. 未捕获异常:协程取消过程中若抛出未处理的 CancellationException,可能直接导致应用崩溃。

题外话

其实早期对于线程我也是这么理解的,我们翻看一下现在的 Java 线程的源码:

@Deprecated(
    since = "1.2",
    forRemoval = true
)
public final void stop() {
    throw new UnsupportedOperationException();
}

public void interrupt() {
    if (this != currentThread()) {
        this.checkAccess();
    }

    this.interrupted = true;
    this.interrupt0();
    //...
}

stop 已经是一个弃用且不可用的接口了。

刚学 Java 的我一直以为,如果我调用 stop 或者 interrupt,Java 一定会完美的帮我取消线程,释放资源。但实际上我们需要写额外的代码来达成这一步,例如判断 interrupted 标志位。

题外话结束,接下来,我们看看如何解决取消操作不当的问题。

编写支持取消的挂起函数

1.png

并非所有挂起函数都天生支持取消

如果调用的是非挂起的阻塞式 API(如遗留库),需通过包装让其响应取消指令,可使用 isActiveyield() 检查取消状态。

以下是支持取消的挂起函数示例:

suspend fun fetchDataFromNetwork(): String = withContext(Dispatchers.IO) {
    // 模拟耗时网络请求
    for (i in 1..10) {
        if (!isActive) return@withContext "已取消" // 取消则退出
        delay(1000) // 模拟网络延迟
    }
    "数据获取成功"
}

该函数中,isActive 会检查协程是否仍处于活跃状态。当 ViewModel 被销毁时,isActive 变为 false,函数会提前退出。

遵循结构化并发原则

结构化并发能确保子协程随父作用域取消而取消

viewModelScope 本身已遵循这一原则,但启动嵌套协程时仍需刻意注意:避免使用 GlobalScope,因其不绑定任何生命周期,通常比 ViewModel 存活更久。

class MyViewModel : ViewModel() {
    private val _data = MutableLiveData<String>()
    val data: LiveData<String> = _data

    fun fetchMultipleData() {
        viewModelScope.launch {
            try {
                val result1 = async { fetchDataFromNetwork() }.await()
                val result2 = async { fetchDataFromNetwork() }.await()
                _data.value = "$result1, $result2"
            } catch (e: CancellationException) {
                _data.value = "操作已取消"
            } catch (e: Exception) {
                _data.value = "错误:${e.message}"
            }
        }
    }
}

此处的 async 协程是 viewModelScope.launch 代码块的子协程,当 ViewModel 被销毁时,所有子协程会自动取消。

针对 CancellationException 的正确做法应该是重新抛出,不过这里只是为了展示当前函数的取消逻辑,所以并没有这么做,文章的后续部分将依然采用这种做法,但是亲爱的读者们,你们要知道这个地方这么做是有待商榷的。

处理取消异常

协程取消时会抛出 CancellationException,这是正常行为,但必须妥善处理以避免意外崩溃。建议始终将协程代码包裹在 try-catch 块中。

class MyViewModel : ViewModel() {
    private val _status = MutableLiveData<String>()
    val status: LiveData<String> = _status

    fun performLongRunningTask() {
        viewModelScope.launch {
            try {
                _status.value = withContext(Dispatchers.IO) {
                    // 耗时任务
                    delay(5000)
                    "任务完成"
                }
            } catch (e: CancellationException) {
                _status.value = "任务已取消"
            } catch (e: Exception) {
                _status.value = "错误:${e.message}"
            }
        }
    }
}

这能确保 ViewModel 销毁时,应用优雅处理取消操作,并同步更新 UI 状态。

避免持有 UI 组件引用

viewModelScope 中的协程若不慎持有 ActivityFragment 的引用,极易引发内存泄漏。

比如向挂起函数传递 Context,可能导致Activity 在取消后仍无法释放。建议改用依赖注入,或显式传递数据。

❌ 错误示例:

class MyViewModel : ViewModel() {
    fun badFetch(activity: Activity) {
        viewModelScope.launch {
            // 持有Activity引用易导致泄漏
            activity.showToast(fetchDataFromNetwork())
        }
    }
}

✅ 优化方案:

class MyViewModel : ViewModel() {
    private val _toastMessage = MutableStateFlow<String>("")
    val toastMessage: Flow<String> = _toastMessage

    fun fetchAndNotify() {
        viewModelScope.launch {
            _toastMessage.value = fetchDataFromNetwork()
        }
    }
}

Activity 中观察 toastMessage 并显示弹窗,实现协程与 UI 解耦。

测试取消行为

取消逻辑的测试虽有难度,但至关重要。可使用 kotlinx-coroutines-test 库的 runBlockingTest 模拟单元测试中的取消场景:

@Test
fun `fetchData cancels correctly`() = runBlockingTest {
    val viewModel = MyViewModel()
    val job = viewModel.viewModelScope.launch {
        viewModel.fetchData()
    }
    job.cancel()
    assertEquals("任务已取消", viewModel.status.value)
}

这能确保协程取消时能正确清理资源。

实战

如果需求需要实现“获取用户数据 → 处理数据 → 更新 UI”的流程,同时要适配配置变更和潜在的取消操作,以下是一个典型的实现方案:

class UserViewModel(private val apiService: ApiService) : ViewModel() {

    private val _userData = MutableStateFlow<User>(DEF_USER)
    val userData: Flow<User> = _userData
    private val _error = MutableStateFlow<String>(DEF_USER)
    val error: Flow<String> = _error

    fun fetchUserProfile(userId: String) {
        viewModelScope.launch {
            try {
                val user = withContext(Dispatchers.IO) {
                    ensureActive() // 执行前检查取消状态
                    val rawData = apiService.getUser(userId)
                    processUserData(rawData) // 耗时处理操作
                }
                _userData.value = user
            } catch (e: CancellationException) {
                _error.value = "用户数据获取已取消"
            } catch (e: Exception) {
                _error.value = "获取用户数据失败:${e.message}"
            }
        }
    }

    private suspend fun processUserData(rawData: RawUserData): User {
        return withContext(Dispatchers.Default) {
            ensureActive() // 处理过程中检查取消状态
            // 模拟复杂数据处理
            delay(2000)
            User(rawData.id, rawData.name)
        }
    }
}

核心要点:

  • ensureActive() 会在协程取消时抛出 CancellationException,让函数具备取消感知能力;
  • Dispatchers.IODispatchers.Default 确保耗时操作脱离主线程;
  • 通过 Flow 传递错误和取消状态,保持 UI 同步。

避坑指南

  1. 忽略取消检查:耗时挂起函数中务必通过 isActiveensureActive() 检查取消状态;
  2. 滥用 GlobalScope:坚持使用 viewModelScope,确保协程与生命周期绑定。如果你的代码中出现了 GlobalScope,你的代码一定有问题;
  3. 忘记处理异常:务必将 CancellationException 与其他异常分开捕获,更通用的做法就是重新抛出;
  4. 阻塞主线程:非 UI 操作需通过 withContext 切换到合适的调度器。

总结

掌握 viewModelScope 取消操作的核心,在于理解生命周期、编写支持取消的代码,并优雅处理边界情况。

遵循这些最佳实践——编写协作式挂起函数、利用结构化并发、妥善处理异常、避免持有 UI 引用,你就能开发出健壮且高效的 Android 应用。

下次遇到莫名的内存泄漏或崩溃时,不妨检查一下协程:往往只需做好取消处理,就能解决问题。

我马上把这篇文章发给我的同事,免得后患无穷。