Kotlin Coroutines 在 Android 项目中的落地实战
一、为什么协程的"基础用法"和"工程落地"是两回事
只会 launch { ... } 并不代表真的在项目里用对了协程。基础教程更多讲语法,但真正上线的项目常见的问题几乎都不是语法问题,而是:
- 作用域选错,页面销毁后任务还在跑
- 异常没接住,直接把宿主协程作用域炸掉
- 取消传播断裂,
isActive检查缺失 - 阻塞主线程的 IO 忘记切线程
- 在
ViewModel里滥用GlobalScope
这些坑没写过一定数量的业务代码几乎踩不到。本文按项目里真实会遇到的场景,把协程的作用域、异常、取消、上下文切换讲清楚,并给出工程上的落地建议。
二、协程作用域怎么选:一张表说清楚
Android 项目里能直接用的作用域主要是四个:
| 作用域 | 生命周期 | 典型场景 | 需要注意的坑 |
|---|---|---|---|
viewModelScope |
跟随 ViewModel,onCleared 时取消 |
拉数据、处理 UI 状态 | 不要在这里做完全独立于 UI 的持久任务 |
lifecycleScope |
跟随 LifecycleOwner | Activity/Fragment 内的一次性 UI 逻辑 | 页面回退时会取消,不适合"必须完成"的任务 |
自定义 CoroutineScope |
由你手动管理 | Repository、单例组件 | 记得在 close 或宿主销毁时 cancel() |
GlobalScope |
应用进程 | 几乎没有合理场景 | 项目里应视为反模式,需要严格评审 |
一个常见误区:把网络请求放在 GlobalScope.launch,理由是"不想被 UI 生命周期打断"。但这样丢失了取消能力,也无法与结构化并发协作,出错时排查非常困难。
正确做法是:请求本身用 viewModelScope,如果确实需要跨页面的持久任务,用带 SupervisorJob 的自定义作用域,并把它挂载到合适的组件(比如 Application 级单例)上,同时提供显式取消能力。
三、结构化并发到底解决了什么问题
结构化并发的核心是:子协程的生命周期必须包含在父作用域内。它带来的直接收益:
- 父作用域取消,所有子协程自动取消
- 子协程未完成前,父作用域不会结束
- 子协程抛出异常,可以按策略传播给父作用域
举一个真实的例子。假设一个页面需要并行拉三个接口:
kotlin
class UserProfileViewModel : ViewModel() {
fun load(userId: String) {
viewModelScope.launch {
val profileDeferred = async { api.profile(userId) }
val ordersDeferred = async { api.orders(userId) }
val couponsDeferred = async { api.coupons(userId) }
val profile = profileDeferred.await()
val orders = ordersDeferred.await()
val coupons = couponsDeferred.await()
_state.value = UiState.Ready(profile, orders, coupons)
}
}
}
用户在数据回来之前退出了页面,viewModelScope 会被取消,三个 async 会一起取消,没有内存泄漏,没有孤儿协程。这就是结构化并发在工程里最直接的价值。
四、异常处理:一个 try/catch 救不了你
初学时容易写出这种代码:
kotlin
viewModelScope.launch {
try {
val profile = async { api.profile(userId) }
val orders = async { api.orders(userId) }
val result = Pair(profile.await(), orders.await())
} catch (e: Exception) {
// 想在这里统一处理
}
}
问题是:如果 api.orders 抛异常,默认的 Job 会立刻取消整个父作用域和兄弟协程,profile.await() 还没等到就被取消了。而且异常会向上冒到父作用域,如果父作用域是 viewModelScope,就会把整个 ViewModel 的协程环境搞坏(虽然 viewModelScope 内部用了 SupervisorJob 兜底,但代码逻辑仍然不完整)。
工程上更稳的写法:
kotlin
viewModelScope.launch {
supervisorScope {
val profile = async { runCatching { api.profile(userId) } }
val orders = async { runCatching { api.orders(userId) } }
val profileResult = profile.await()
val ordersResult = orders.await()
_state.value = when {
profileResult.isFailure -> UiState.Error(profileResult.exceptionOrNull())
else -> UiState.Ready(profileResult.getOrThrow(), ordersResult.getOrNull())
}
}
}
要点:
supervisorScope让一个子协程失败不会连累兄弟runCatching把异常收敛成Result,便于统一处理- 顶层依然由
viewModelScope管理生命周期
五、CoroutineExceptionHandler 什么时候用
CoroutineExceptionHandler 只对未被捕获的异常 生效,且必须挂在协程作用域或最顶层的协程上。它常被误用为 try/catch 的替代,但两者定位不同:
try/catch:处理业务上的可预期错误CoroutineExceptionHandler:兜底未捕获异常,防止应用崩溃
一个实用模式是把它挂在自定义 Application Scope 上,上报到崩溃平台:
kotlin
object AppScope : CoroutineScope {
private val handler = CoroutineExceptionHandler { _, throwable ->
Crashlytics.recordException(throwable)
}
override val coroutineContext =
SupervisorJob() + Dispatchers.Default + handler
}
注意:CoroutineExceptionHandler 对 async 没用。async 的异常只在 await() 时抛出,需要显式 try/catch。
六、取消传播:写业务代码时的隐形坑
协程的取消是协作式的。这意味着:
- 挂起函数在挂起点会检查取消
- 纯 CPU 密集循环不会自动响应取消
- 忽略
CancellationException会导致取消传播失败
典型问题代码:
kotlin
suspend fun heavyCompute(): Result {
var sum = 0L
for (i in 0..Int.MAX_VALUE) {
sum += i // 完全不响应取消
}
return Result(sum)
}
修复方案:
kotlin
suspend fun heavyCompute(): Result = withContext(Dispatchers.Default) {
var sum = 0L
for (i in 0..Int.MAX_VALUE) {
ensureActive() // 或 yield(),主动检查取消
sum += i
}
Result(sum)
}
另一个隐形坑是"吞掉"了 CancellationException:
kotlin
// ❌ 错误:吞掉了取消
try {
doSomething()
} catch (e: Exception) {
log(e)
}
// ✅ 正确:让取消继续传播
try {
doSomething()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(e)
}
Kotlin 1.6 之后可以直接用 NonCancellable、withContext(NonCancellable) 处理"取消后仍要执行的清理"。
七、上下文切换:withContext 是最常被忽视的工具
Android 上最常见的性能问题之一是"以为切了线程,其实没切"。规则很简单:
- 网络、磁盘 IO:
Dispatchers.IO - CPU 密集计算:
Dispatchers.Default - UI 更新:
Dispatchers.Main - 与 Java 回调桥接:
Dispatchers.Main.immediate
一个好的实践是:在 Repository 层就把 IO 切好,UI 层不需要关心:
kotlin
class UserRepository(private val api: UserApi, private val dao: UserDao) {
suspend fun getUser(id: String): User = withContext(Dispatchers.IO) {
dao.get(id) ?: api.fetch(id).also { dao.save(it) }
}
}
这样 ViewModel 里可以直接 viewModelScope.launch { repo.getUser(id) },不必操心线程。
八、协程与 Flow 结合的场景
Flow 是协程的补充,处理连续流式数据。在 Android 项目里常见场景:
- 表单输入实时校验(
debounce+distinctUntilChanged) - 数据库变更监听(Room 返回
Flow<T>) - 网络轮询与推送
- 多状态合并(
combine)
一个典型模式:
kotlin
class SearchViewModel(private val repo: SearchRepo) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<List<Item>> = query
.debounce(300)
.distinctUntilChanged()
.filter { it.isNotBlank() }
.flatMapLatest { repo.search(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun onQueryChange(q: String) { query.value = q }
}
要点:
flatMapLatest保证新查询到来时取消上一个stateIn把冷流转成热流,页面重建时不会重新请求WhileSubscribed(5000)允许页面短暂离开时保留结果,回来立即可用
九、线上问题排查清单
以下是我在项目里总结的排查顺序:
| 现象 | 优先排查 | 常见根因 |
|---|---|---|
| ANR,主线程阻塞 | 是否有 IO 未切到 Dispatchers.IO |
忘记 withContext |
| 页面退出后数据仍到达 | 用的是 GlobalScope 或裸 CoroutineScope |
未使用 viewModelScope/lifecycleScope |
| 崩溃日志显示 CancellationException | 吞掉了取消异常 | catch (e: Exception) 没重新抛出 |
| 一个接口失败导致全部失败 | 用了普通 coroutineScope |
应改为 supervisorScope |
| 内存泄漏 | 自定义作用域未 cancel | 宿主销毁时未清理 |
十、工程落地建议清单
给项目团队的具体建议:
- 禁用
GlobalScope:加 Detekt 或 Lint 规则拦截 - Repository 层封装线程切换 :
withContext(Dispatchers.IO)只出现在 Repository - 统一异常处理 :
CoroutineExceptionHandler挂在 Application Scope,上报到崩溃平台 - 谨慎使用
runBlocking:只在测试和 main 函数入口用 - 协程测试用
runTest:搭配TestDispatcher精准控制虚拟时间 - Code Review 重点关注 :
launch的 scope、async的await、try/catch是否漏了CancellationException
十一、总结
协程不是让代码"看起来像同步的异步语法糖"这么简单。真正落地时,需要理解:
- 作用域和生命周期的绑定关系
- 结构化并发的取消与异常传播
SupervisorJob与普通Job的区别try/catch与CoroutineExceptionHandler的分工- 挂起函数的取消协作规则
用对协程能极大简化异步代码;用错则容易埋下比回调地狱更隐蔽的问题。项目里最有效的做法是:先定规范,再写代码。让作用域、线程切换、异常处理这些约定统一,团队协作才能可持续。
如果这篇文章对你的项目有帮助,欢迎点赞收藏,有实战踩坑经验也欢迎交流。