Kotlin Coroutines 在 Android 项目中的落地实战

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 级单例)上,同时提供显式取消能力。

三、结构化并发到底解决了什么问题

结构化并发的核心是:子协程的生命周期必须包含在父作用域内。它带来的直接收益:

  1. 父作用域取消,所有子协程自动取消
  2. 子协程未完成前,父作用域不会结束
  3. 子协程抛出异常,可以按策略传播给父作用域

举一个真实的例子。假设一个页面需要并行拉三个接口:

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
}

注意:CoroutineExceptionHandlerasync 没用。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 之后可以直接用 NonCancellablewithContext(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 宿主销毁时未清理

十、工程落地建议清单

给项目团队的具体建议:

  1. 禁用 GlobalScope:加 Detekt 或 Lint 规则拦截
  2. Repository 层封装线程切换withContext(Dispatchers.IO) 只出现在 Repository
  3. 统一异常处理CoroutineExceptionHandler 挂在 Application Scope,上报到崩溃平台
  4. 谨慎使用 runBlocking:只在测试和 main 函数入口用
  5. 协程测试用 runTest :搭配 TestDispatcher 精准控制虚拟时间
  6. Code Review 重点关注launch 的 scope、asyncawaittry/catch 是否漏了 CancellationException

十一、总结

协程不是让代码"看起来像同步的异步语法糖"这么简单。真正落地时,需要理解:

  • 作用域和生命周期的绑定关系
  • 结构化并发的取消与异常传播
  • SupervisorJob 与普通 Job 的区别
  • try/catchCoroutineExceptionHandler 的分工
  • 挂起函数的取消协作规则

用对协程能极大简化异步代码;用错则容易埋下比回调地狱更隐蔽的问题。项目里最有效的做法是:先定规范,再写代码。让作用域、线程切换、异常处理这些约定统一,团队协作才能可持续。


如果这篇文章对你的项目有帮助,欢迎点赞收藏,有实战踩坑经验也欢迎交流。

相关推荐
不一样的少年_1 小时前
不用框架,手搓 AI Agent:(一) 先让它跑起来
前端·后端·agent
味悲1 小时前
浏览器解析机制与XSS的15种编码绕过
前端·xss
小牛itbull1 小时前
【译】为什么我使用 React 重构了 WordPress?
前端·react.js·重构
用户059540174461 小时前
AI Agent 记忆存储踩坑实录:这个问题让我排查了 3 天,最终用 pytest + Docker 实现秒级回归
前端·css
今日无bug1 小时前
Emmet 语法速成指南
前端·css·html
Hello.Reader1 小时前
Tailwind CSS v4.3 从入门到实战Vite 安装、响应式布局、暗黑模式与主题配置
前端·css
kisshyshy2 小时前
前端存储、表单异步提交与 this 指向完全指南
前端·javascript·html
cidy_982 小时前
Skills 技能库- JulyCode
前端
Csvn2 小时前
TypeScript 泛型约束的 3 层演进:从 `extends` 到条件类型到重载推导
前端