Android Kotlin 协程中的异常处理与任务取消
Android Kotlin 协程中的异常处理与任务取消
协程让异步代码更接近顺序写法,但“代码更短”不代表异常会自动得到正确处理。实际项目中,最常见的问题包括子任务失败导致整个作用域取消、取消异常被误当作普通错误、界面离开后任务仍在执行,以及并发请求之间缺少明确关系。
一、先理解结构化并发
结构化并发要求协程存在清晰的父子关系。父协程结束时,子协程也应结束;普通子协程失败时,异常会向上传递并取消同级任务。
viewModelScope.launch {
val profile = async { repository.loadProfile() }
val messages = async { repository.loadMessages() }
val uiModel = HomeUiModel(
profile = profile.await(),
messages = messages.await()
)
_uiState.value = HomeUiState.Content(uiModel)
}
这段代码表达了明确语义:两个结果都成功,首页数据才完整。如果任何一个请求失败,另一个任务也会被取消。对于必须成组成功的业务,这是合理行为。
二、区分业务失败与协程取消
协程通过 CancellationException 传播取消信号。捕获所有异常后不再抛出取消异常,会破坏取消机制,使已经离开页面的任务继续运行。
suspend fun loadProfileSafely(): Profile {
return try {
repository.loadProfile()
} catch (exception: CancellationException) {
throw exception
} catch (exception: IOException) {
throw ProfileLoadException(exception)
}
}
如果使用 runCatching 包裹挂起调用,也要确认当前 Kotlin 版本和项目封装是否会吞掉取消信号。更容易审查的做法,是只捕获确实能够处理的异常类型。
三、在正确层级转换错误
数据层负责把底层异常转换为稳定的业务错误,ViewModel 决定错误如何成为页面状态,界面层只负责展示。
sealed interface LoadResult<out T> {
data class Success<T>(val data: T) : LoadResult<T>
data object NetworkUnavailable : LoadResult<Nothing>
data object Unauthorized : LoadResult<Nothing>
data class ServerRejected(val reason: String) : LoadResult<Nothing>
}
不要把底层异常文本原样显示给用户。异常信息可能不稳定,也可能包含用户无法理解的技术细节。业务层应根据协议中已经约定的错误类型进行映射,无需为不存在的字段设计大量猜测性分支。
四、按业务关系选择 supervisorScope
有些页面允许局部失败。例如首页包含账户、公告和推荐模块,公告加载失败不应阻止账户信息显示。这时可以使用 supervisorScope 隔离子任务失败,但仍需分别处理每个结果。
suspend fun loadDashboard(): DashboardData = supervisorScope {
val account = async { repository.loadAccount() }
val noticeResult = async {
try {
NoticeResult.Success(repository.loadNotices())
} catch (exception: CancellationException) {
throw exception
} catch (exception: IOException) {
NoticeResult.Failed
}
}
DashboardData(
account = account.await(),
noticeResult = noticeResult.await()
)
}
supervisorScope 不是通用保险开关。只有子任务确实允许独立失败时才应使用,否则会掩盖原本需要整体失败的业务关系。
五、避免重复任务
搜索联想、输入校验等场景中,新请求到来时旧结果已经没有价值,应主动取消前一个任务。
private var searchJob: Job? = null
fun search(keyword: String) {
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300)
val result = repository.search(keyword)
_searchState.value = SearchState.Content(result)
}
}
对于连续数据流,也可以使用 mapLatest 或 flatMapLatest 表达“只关心最新输入”。选择哪种方式,应与项目现有架构保持一致。
六、确保耗时任务可取消
挂起函数通常会在挂起点检查取消,但纯计算循环可能长时间没有挂起点。可以周期性调用 ensureActive:
suspend fun calculate(items: List<RawItem>): List<ResultItem> {
return withContext(Dispatchers.Default) {
items.mapIndexed { index, item ->
if (index % 100 == 0) ensureActive()
transform(item)
}
}
}
网络、数据库和文件组件也应支持取消。若底层调用不可取消,上层取消只能停止等待,无法真正终止资源消耗。
七、不要随意创建全局作用域
业务任务如果脱离页面和应用组件的生命周期,就很难确定何时结束。界面相关任务使用 lifecycleScope,页面业务状态使用 viewModelScope,应用级任务则交给项目已有的后台任务体系。
只有确实需要跨页面持续执行的任务,才应该提升作用域,而且需要明确负责人、停止条件和失败处理策略。
八、测试异常与取消路径
协程测试不能只覆盖成功结果,还应包含:
- 第一个并发任务失败时,其他任务是否按预期取消。
- 页面销毁或新搜索到来时,旧任务是否停止。
- 取消是否被错误地转换为失败提示。
- 局部模块失败时,其他模块是否仍能展示。
- 重试过程中是否会产生两个同时运行的请求。
通过可控制的测试调度器和伪数据源,可以稳定复现这些时序,不必依赖真实等待。
总结
协程异常处理的关键不是到处添加 try-catch,而是明确任务之间的关系。需要共同成功时使用普通父子作用域,允许局部失败时谨慎使用监督作用域;始终保留取消语义,并把底层异常转换为稳定业务状态。只有作用域、异常和取消三者边界都清晰,异步代码才真正可靠。
- 点赞
- 收藏
- 关注作者
评论(0)