昨天,我写过这样一篇文章,里面讲述了一个可能 90% 的开发者都答不上来问题。
中午我和同事吃饭的时候,他问我:然后呢,你就水一篇文章不给解决方案?你知道程序员流行一句什么样的话吗?——留图不留种,xx万人x。
OK,打住!
这里,我们从 viewModelScope 入手,看看这个取消的问题!
viewModelScope
我知道,作为一名安卓开发者,都曾花费无数时间调试各种诡异的崩溃问题和内存泄漏,而这些问题的根源往往都指向协程处理不当。
如果使用 Kotlin 协程,大概率对 viewModelScope 并不陌生。
它是将协程与 ViewModel 生命周期绑定的绝佳工具,但如果取消操作处理不当,麻烦便会接踵而至。
泄漏的协程会持续占用资源、浪费 CPU 算力,更糟的是,还可能导致应用行为异常。
在本文中,我将分享自己摸索出的 viewModelScope 取消操作实战经验,结合具体示例和技巧,避免被“xx万人x”。
为何取消操作至关重要
viewModelScope 是与 ViewModel 生命周期绑定的协程作用域。
当 ViewModel 被销毁时(比如关联的 Activity 或 Fragment 销毁),该作用域会自动取消所有关联的协程。
听起来很完美?
但关键问题在于:自动取消不代表可以高枕无忧。稍不注意,协程仍可能超时运行、占用资源,或触发未捕获异常导致应用崩溃。
还记得之前的文章的那句话吗?协程的取消是协作的。
规范的取消操作能确保应用始终轻量、行为可预测,尤其是在配置变更或进程终止的场景下。
接下来,我们聊聊如何专业地管理 viewModelScope 的取消逻辑。
理解 viewModelScope
viewModelScope 是 androidx.lifecycle:lifecycle-viewmodel-ktx 库提供的属性,本质是绑定到 Dispatchers.Main 主线程、且与 ViewModel 生命周期关联的 CoroutineScope。
当 ViewModel 的 onCleared() 方法被调用时,该作用域会取消所有协程。
这对 Android 开发而言堪称绝配——毕竟 UI 组件的生命周期本就多变,但这一切的前提是,你编写的代码要具备“取消感知”能力。
以下是使用 viewModelScope 的简单 ViewModel 示例:
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch {
val data = fetchDataFromNetwork() // 挂起函数
// 更新UI
}
}
}
当 ViewModel 被销毁(比如用户跳转页面),协程会被取消。
但如果 fetchDataFromNetwork() 是耗时操作,情况会如何?
取消操作不当的常见陷阱
在我早期的项目中,我曾以为 viewModelScope 能“魔法般”处理所有问题,实际上根本不可能。
以下是我踩过的典型坑:
- 不可取消的操作:部分挂起函数(如第三方库调用)不响应取消指令,导致资源持续占用。或者,你调用的函数就是一个普通函数,根本不是挂起函数,不可能响应取消指令;
- 资源泄漏:若协程持有
Context或View的引用,即便取消协程也无法释放资源,最终引发内存泄漏; - 未捕获异常:协程取消过程中若抛出未处理的
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 标志位。
题外话结束,接下来,我们看看如何解决取消操作不当的问题。
编写支持取消的挂起函数
并非所有挂起函数都天生支持取消。
如果调用的是非挂起的阻塞式 API(如遗留库),需通过包装让其响应取消指令,可使用 isActive 或 yield() 检查取消状态。
以下是支持取消的挂起函数示例:
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 中的协程若不慎持有 Activity 或 Fragment 的引用,极易引发内存泄漏。
比如向挂起函数传递 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.IO和Dispatchers.Default确保耗时操作脱离主线程;- 通过
Flow传递错误和取消状态,保持UI同步。
避坑指南
- 忽略取消检查:耗时挂起函数中务必通过
isActive或ensureActive()检查取消状态; - 滥用 GlobalScope:坚持使用
viewModelScope,确保协程与生命周期绑定。如果你的代码中出现了GlobalScope,你的代码一定有问题; - 忘记处理异常:务必将
CancellationException与其他异常分开捕获,更通用的做法就是重新抛出; - 阻塞主线程:非
UI操作需通过withContext切换到合适的调度器。
总结
掌握 viewModelScope 取消操作的核心,在于理解生命周期、编写支持取消的代码,并优雅处理边界情况。
遵循这些最佳实践——编写协作式挂起函数、利用结构化并发、妥善处理异常、避免持有 UI 引用,你就能开发出健壮且高效的 Android 应用。
下次遇到莫名的内存泄漏或崩溃时,不妨检查一下协程:往往只需做好取消处理,就能解决问题。
我马上把这篇文章发给我的同事,免得后患无穷。