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
}

注意: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 宿主销毁时未清理

十、工程落地建议清单

给项目团队的具体建议:

  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、async 的 await、try/catch 是否漏了 CancellationException

十一、总结

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

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

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


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

相关推荐
福兮说1 小时前
设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
前端·javascript·css·canvas
郑州光合科技余经理2 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
小呆呆6662 小时前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
Dovis(誓平步青云)3 小时前
浇水提醒刚弹出又消失,植物状态别只存一个百分比
开发语言·前端·javascript·pdf·ecmascript·电脑
码艺-Alimjan3 小时前
Vben Admin 新增维吾尔语 Vben-Modal的关键坑之一
前端·javascript·vue.js
可乐鸡翅yeah_4 小时前
hls.js 手动自定义 http 请求 loader,修改请求头实战
开发语言·前端·javascript·网络协议·http·ecmascript·m3u8在线
IT_陈寒4 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端
广州华水科技4 小时前
大坝安全监测解决方案:单北斗GNSS形变监测系统应用与维护
前端
yivifu5 小时前
中文古籍电子书注释集成
前端·javascript·python·beautifulsoup·epub
默_笙6 小时前
🛴 从散件到整机:DeepAgents 与 Agent 身上预留的那些"插槽"(前置介绍)
前端·javascript