Kotlin 协程:从入门到深度理解

Kotlin 协程:从入门到深度理解

很多人学 Kotlin 协程时,都会经历一个很奇怪的阶段:

一开始觉得它很简单:

"哦,suspend 就是异步。"

"launch 开个协程。"

"async 拿个结果。"

"withContext(Dispatchers.IO) 切线程。"

然后一上项目,立刻就乱了。

为什么这里要 suspend,那里不能加?

为什么 launch 里的异常有时会崩,有时又不崩?

为什么取消了页面,网络请求还在跑?

为什么有人说不要乱用 GlobalScope

为什么 Flow 和协程关系这么近?

为什么大家都在讲"结构化并发"?

这篇文章的目标不是让你"知道协程 API 有哪些",而是让你真正建立一套脑内模型:

  • 协程到底是什么
  • 它和线程、回调、Future、Rx 的关系是什么
  • suspend 到底意味着什么
  • 协程如何调度、如何取消、如何传播异常
  • 实战里如何组织代码结构
  • Android / 后端开发中协程到底应该怎么用

读完以后,你不但会写,还会知道为什么这么写。


一、先别急着写代码:协程到底解决什么问题

先看一个最原始的问题。

你有一段逻辑:

  1. 查用户信息
  2. 再查用户订单
  3. 再查订单详情
  4. 最后渲染页面

如果你用同步阻塞写法,大概是这样:

ini 复制代码
val user = api.getUser()
val orders = api.getOrders(user.id)
val details = api.getOrderDetails(orders.first().id)
render(details)

代码非常直观,按顺序阅读即可。

问题是:如果这些操作是网络请求,线程会被阻塞。

于是早期大家会用回调:

javascript 复制代码
api.getUser { user ->
    api.getOrders(user.id) { orders ->
        api.getOrderDetails(orders.first().id) { details ->
            render(details)
        }
    }
}

这就开始难受了:

  • 逻辑嵌套
  • 错误处理分散
  • 取消困难
  • 生命周期难管理
  • 并发组合复杂

后来又出现 Future / Promise / Rx 之类方案,问题缓解了很多,但学习成本、组合复杂度、调试体验仍然不低。

协程想解决的核心问题可以用一句话概括:

用接近同步代码的写法,表达异步、非阻塞、可取消、可组合的并发逻辑。

重点有四个:

  • 接近同步写法
  • 实际是异步非阻塞
  • 支持取消
  • 支持结构化管理

后两个才是协程真正厉害的地方。

很多人只学到了"写起来像同步",但没学懂"结构化并发"和"取消传播",所以项目里仍然会乱。


二、协程不是线程,它到底是什么

先下一个最重要的结论:

协程不是线程。

线程是操作系统调度的执行单元。

协程是运行在线程之上的、由程序/运行时管理的轻量级任务。

你可以这样理解:

  • 线程像"工人"
  • 协程像"工单"
  • 一个工人可以在不同时间处理很多工单
  • 协程挂起时,不会像阻塞线程那样把工人卡死
  • 调度器可以让工人去干别的活,等这个协程能继续了再回来

所以协程的关键不在"并行",而在"挂起"。


2.1 阻塞和挂起的本质区别

假设你调用:

bash 复制代码
Thread.sleep(1000)

这是阻塞。

线程在这 1 秒什么都干不了。

如果你调用:

scss 复制代码
delay(1000)

这是挂起。

当前协程暂停 1 秒,但线程可以去执行别的协程。

这就是为什么很多人会说:

协程让你以同步风格写异步代码。

因为逻辑顺序还在,但底层不是"线程傻等",而是"协程挂起,线程腾出来"。


2.2 协程为什么轻量

线程创建和切换都很贵,涉及内核调度、栈空间等。

协程由 Kotlin 协程库管理,创建和切换成本小得多。

所以你可以轻松创建成千上万个协程,这在同等规模线程下往往不现实。

但注意:

轻量不等于免费。

协程虽然便宜,但乱开协程、无限制并发、错误调度,照样会把系统搞崩。


三、第一层理解:最基本的协程代码怎么读

先看最常见示例:

kotlin 复制代码
import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000)
        println("World")
    }
    println("Hello")
}

输出:

复制代码
Hello
World

这段代码里有几个角色。


3.1 runBlocking

runBlocking 会启动一个协程,并阻塞当前线程直到协程内部执行结束。

它的典型用途只有两个:

  • 测试
  • main 函数示例代码

它的名字就已经告诉你了:run + blocking

所以它不是"现代协程标准写法",只是桥接普通阻塞世界和协程世界的工具。

实战中不要在 UI 线程、请求线程里乱用 runBlocking


3.2 launch

launch 用来启动一个新协程,不返回业务结果,只返回一个 Job

ini 复制代码
val job: Job = launch {
    delay(1000)
}

你可以把它理解为:

"去做这件事,我关心它的生命周期,不直接要返回值。"

适合:

  • 发起一个任务
  • 更新 UI
  • 事件处理
  • 消费消息

3.3 async

async 启动一个协程,并返回 Deferred<T>,本质上是"将来会有结果的 Job"。

dart 复制代码
val deferred: Deferred<Int> = async {
    delay(1000)
    42
}

val result = deferred.await()

适合:

  • 并发计算
  • 并发请求后汇总结果

3.4 delay

delay 是挂起函数。

它不会阻塞线程,只会挂起协程。

这就是协程和线程 API 最大的感官差异之一。


四、真正入门的关键:suspend 到底是什么

很多人学协程,最模糊的地方就是 suspend

先给一个非常准确的理解:

suspend 不是"异步"的意思,而是"这个函数可以在执行过程中挂起,并且只能在协程或其他挂起函数中调用"。

这句话很重要。


4.1 suspend 不是自动开线程

很多初学者误以为:

kotlin 复制代码
suspend fun loadData() { ... }

等价于"异步执行"。

不是。

suspend 本身不创建线程,不保证并发,也不自动切换调度器。

它只表示:

  • 这个函数可能会挂起
  • 调用方必须处在协程上下文中

例如:

kotlin 复制代码
suspend fun loadData(): String {
    delay(1000)
    return "data"
}

这里 delay 会挂起,但不代表 loadData 自动在后台线程跑。

它跑在哪个线程,取决于调用它时所在的协程上下文。


4.2 为什么挂起函数只能在协程里调用

因为普通函数没有"暂停后再恢复"的机制。

协程运行时会把挂起点的信息保存起来,等条件满足后再恢复执行。

你可以把它理解为:

  • 普通函数:一口气跑到底
  • 挂起函数:跑到一半可以"存档",之后再"读档继续"

这个"存档 + 恢复"的能力,需要协程框架配合。


4.3 suspend 让同步风格成为可能

比如:

kotlin 复制代码
suspend fun fetchUser(): User
suspend fun fetchOrders(userId: String): List<Order>
suspend fun fetchDetail(orderId: String): Detail

suspend fun loadPage(): Detail {
    val user = fetchUser()
    val orders = fetchOrders(user.id)
    return fetchDetail(orders.first().id)
}

写法像同步串行代码,但每一步都可以是挂起的、非阻塞的。

这就是协程代码最迷人的地方:顺序思维 + 非阻塞执行


五、第二层理解:协程是怎么"挂起再恢复"的

这一层不用死磕编译器细节,但一定要有直觉。

Kotlin 编译器会把 suspend 函数转换成一种"状态机"。

比如伪代码:

scss 复制代码
suspend fun task() {
    step1()
    delay(1000)
    step2()
}

概念上会变成:

  • 执行 step1
  • delay 时保存当前执行位置
  • 暂时返回,不占着线程
  • 1 秒后恢复
  • 从保存的位置继续执行 step2

所以协程挂起不是"把整个线程冻住",而是"把当前任务的执行状态保存起来"。

这也是为什么协程能轻量。


六、协程上下文:你必须真正理解的核心对象

每个协程都有一个 CoroutineContext

这是协程世界里最重要的基础设施之一。

它通常包含这些东西:

  • Job
  • Dispatcher
  • CoroutineName
  • CoroutineExceptionHandler
  • 其他自定义上下文元素

6.1 Job:生命周期和取消控制

Job 负责:

  • 协程是否还活着
  • 是否取消
  • 父子关系
  • 完成状态

示例:

scss 复制代码
val job = launch {
    delay(5000)
    println("done")
}

delay(1000)
job.cancel()

这里 job.cancel() 会取消协程。


6.2 Dispatcher:决定协程在哪类线程运行

常见调度器:

  • Dispatchers.Default
  • Dispatchers.IO
  • Dispatchers.Main
  • Dispatchers.Unconfined

最常用前三个。

Dispatchers.Default

适合 CPU 密集型任务:

  • JSON 复杂转换
  • 排序
  • 计算
  • 数据处理

Dispatchers.IO

适合 IO 密集型任务:

  • 网络请求
  • 文件读写
  • 数据库访问

Dispatchers.Main

Android/UI 场景主线程更新 UI。


6.3 一个常见误区:协程不等于后台线程

很多人写:

scss 复制代码
launch {
    doSomething()
}

就以为在后台执行了。

不一定。

如果你的 scope 在主线程上下文,那它就在主线程跑。

所以你要明确知道当前协程上下文是什么


七、结构化并发:协程最重要的设计思想

如果你只记住协程一个思想,请记这个:

结构化并发(Structured Concurrency)

它的意思是:

一个协程里启动的子协程,不应该像野线程一样失控;它们应该被纳入父作用域统一管理,生命周期清晰、取消可传播、异常可收敛。

这和传统"随手开线程"最大的区别就在这里。

结构化并发(Structured Concurrency) 是 Kotlin 协程中一个核心的设计理念。简单来说,它就像是家长的"责任制":父协程必须负责所有子协程的生命周期,直到所有孩子都回家(执行完毕),家长才能关灯睡觉(结束任务)。

在没有结构化并发的年代(比如早期的多线程),启动一个任务后它就像脱缰的野马,即使主程序崩溃了,那个线程可能还在后台空转,导致内存泄漏或资源浪费。


7.1 为什么结构化并发重要

先看坏例子:

kotlin 复制代码
fun load() {
    GlobalScope.launch {
        val user = api.getUser()
        save(user)
    }
}

这段代码的问题是:

  • load() 返回了,但后台任务还在跑
  • 调用方不知道它什么时候结束
  • 出错了也难统一管理
  • 页面销毁了它可能还在继续
  • 测试很难控制

这就是"任务逃逸"。

而结构化并发要求:

  • 子协程属于某个作用域
  • 父作用域结束时,子协程一起结束
  • 父作用域取消时,子协程也取消
  • 子协程异常按照规则向上传递

7.2 coroutineScope

kotlin 复制代码
suspend fun loadData() = coroutineScope {
    val user = async { api.getUser() }
    val orders = async { api.getOrders() }
    combine(user.await(), orders.await())
}

coroutineScope 的意义是:

  • 创建一个新的协程作用域
  • 其中所有子协程完成前,不会返回
  • 任一子协程失败,整个作用域失败
  • 其余子协程会被取消

这就是"一个任务树"。


7.3 supervisorScope

kotlin 复制代码
suspend fun loadWidgets() = supervisorScope {
    val a = async { loadA() }
    val b = async { loadB() }
    val c = async { loadC() }

    // 某个失败,不一定拖垮全部
}

supervisorScopecoroutineScope 的区别很关键:

  • coroutineScope:一个孩子失败,兄弟通常都取消
  • supervisorScope:孩子失败,不自动干掉兄弟

适用场景:

  • 多个子任务相互独立
  • 一个失败不影响其他结果
  • 比如首页多个卡片并行加载,某个模块失败不应该让整个页面全挂

八、launchasync 不只是"有无返回值"这么简单

初学时大家常说:

  • launch 没返回值
  • async 有返回值

这话没错,但远远不够。


8.1 launch 更偏"任务型"

markdown 复制代码
launch {
    repository.refresh()
}

语义是:

"启动一件事。"

你会关心:

  • 它什么时候结束
  • 能不能取消
  • 是否失败

但通常不会直接 await 业务结果。


8.2 async 更偏"结果型"

ini 复制代码
val userDeferred = async { api.getUser() }
val orderDeferred = async { api.getOrders() }

val user = userDeferred.await()
val orders = orderDeferred.await()

语义是:

"启动一件未来会给结果的事。"


8.3 实战里别滥用 async

很多人把 async 当成默认启动方式,这是不好的。

因为 async 的核心价值是"并发拿结果"。

如果你只是执行一个动作,不需要结果,优先用 launch

更重要的是,async 的异常常常在 await() 时才显式暴露,这容易让错误传播路径变得不直观。


九、取消机制:协程为什么比线程友好得多

取消是协程实战的核心能力之一。

线程取消很粗暴,而协程取消是协作式取消

意思是:

协程不会被粗暴杀死,而是在挂起点或主动检查取消状态时,自己配合退出。


9.1 基本取消

scss 复制代码
val job = launch {
    repeat(1000) { i ->
        delay(500)
        println(i)
    }
}

delay(1200)
job.cancel()

这里因为 delay 是可取消挂起点,所以取消会很快生效。


9.2 为什么有些协程取消不了

比如:

css 复制代码
val job = launch(Dispatchers.Default) {
    var i = 0
    while (i < 1000000000) {
        i++
    }
}
job.cancel()

这段可能不会及时停,因为循环里没有挂起点,也没有主动检查取消状态。

解决办法:

ini 复制代码
val job = launch(Dispatchers.Default) {
    var i = 0
    while (isActive) {
        i++
    }
}

或者:

scss 复制代码
yield()
ensureActive()

9.3 CancellationException

协程取消本质上通过 CancellationException 传播。

这很重要,因为它意味着:

  • 取消是一种"正常控制流"
  • 不应该把取消当普通错误吞掉

比如坏写法:

scss 复制代码
try {
    delay(1000)
} catch (e: Exception) {
    println("ignore")
}

这样可能把取消也吞了。

更好的写法:

php 复制代码
try {
    delay(1000)
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    println("real error")
}

或者更简单地只捕获你真正关心的异常类型。


9.4 finally 和不可取消清理

取消后,如果你需要清理资源:

java 复制代码
val job = launch {
    try {
        delay(5000)
    } finally {
        println("clean up")
    }
}

如果 finally 中还要调用挂起函数,需要:

scss 复制代码
finally {
    withContext(NonCancellable) {
        delay(100)
        println("cleanup done")
    }
}

因为取消状态下普通挂起函数可能立刻抛取消异常。


十、异常传播:为什么有时崩,有时不崩

这是协程最容易让人困惑的点之一。


10.1 launch 的异常

launch 启动的协程,如果它是根协程,未捕获异常会直接抛给异常处理机制。

java 复制代码
val job = launch {
    throw RuntimeException("boom")
}

如果没有处理,通常会导致崩溃或被异常处理器接住。


10.2 async 的异常

async 更特别:

dart 复制代码
val deferred = async {
    throw RuntimeException("boom")
}

异常通常会在你 await() 时暴露:

scss 复制代码
try {
    deferred.await()
} catch (e: Exception) {
    println(e.message)
}

所以很多人会误以为 async 没报错,其实只是你还没 await()


10.3 父子协程异常传播

在普通结构化并发中:

  • 子协程失败
  • 父协程失败
  • 兄弟协程被取消

这就是默认"同生共死"的策略。

如果你不想这样,就用 SupervisorJobsupervisorScope


10.4 CoroutineExceptionHandler 的边界

很多人以为加了 CoroutineExceptionHandler 就万事大吉。

不是。

它主要对根协程未捕获异常 有效。

对子协程、对 asyncawait 异常,不是你想的那种"全能兜底器"。

实战里更可靠的思路是:

  • 在合适的层级显式处理异常
  • 区分业务异常、取消异常、系统异常
  • 用 supervisor 模式隔离互不影响的子任务

十一、上下文切换:withContext 到底该怎么理解

这是协程实战里最常用的 API 之一。

scss 复制代码
val result = withContext(Dispatchers.IO) {
    api.getData()
}

它的意思是:

  • 切到指定上下文执行代码块
  • 执行结束后回到原上下文
  • 返回结果

注意,它和 launch(IO) 的语义不同。


11.1 withContext 适合"我要这个结果"

kotlin 复制代码
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
    api.getUser()
}

这是很自然的写法。


11.2 withContext 不是"越多越好"

不少初学者会写成:

scss 复制代码
withContext(Dispatchers.IO) {
    val user = withContext(Dispatchers.IO) { api.getUser() }
    val orders = withContext(Dispatchers.IO) { api.getOrders() }
}

这就很别扭。

如果你已经在 IO 上下文里,就没必要重复切。

过度切换上下文会增加理解负担,也可能有额外成本。


11.3 一个重要实践原则

谁负责切线程?要有明确分层约定。

比如:

  • ViewModel 不直接关心 IO 细节,调用 Repository
  • Repository 内部使用 withContext(Dispatchers.IO) 做阻塞 IO
  • 或者更进一步,用依赖注入把 dispatcher 注入进去,便于测试

例如:

kotlin 复制代码
class UserRepository(
    private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun loadUser(): User = withContext(ioDispatcher) {
        api.getUser()
    }
}

这比在上层到处乱切线程更清晰。


十二、协程作用域:实战组织代码的关键

协程不是"随手 launch 就行"。

你必须知道协程属于哪个 scope。


12.1 常见作用域

应用级作用域

生命周期跟应用一致,适合长期后台任务。

页面/组件级作用域

Android 里典型是 viewModelScopelifecycleScope

请求级作用域

服务端一次 HTTP 请求对应一个 scope,请求结束则取消。

自定义业务作用域

某个模块需要统一管理其子任务。


12.2 为什么不要乱用 GlobalScope

GlobalScope 的问题不是"不能用",而是几乎不适合业务默认使用

它的问题:

  • 生命周期太长
  • 脱离调用方控制
  • 取消困难
  • 测试困难
  • 容易泄漏后台任务

它适合极少数"跟整个进程同寿命"的顶层任务。

绝大多数业务代码不该用它。


十三、Android 实战:协程最常见、最正确的用法

如果你是 Android 开发,下面这部分非常关键。


13.1 ViewModel 中发起任务

kotlin 复制代码
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    fun load() {
        viewModelScope.launch {
            try {
                val user = repository.loadUser()
                // 更新 UI state
            } catch (e: Exception) {
                // 更新 error state
            }
        }
    }
}

为什么这里适合 viewModelScope

因为:

  • 页面销毁时 ViewModel 清理
  • 作用域自动取消
  • 不会有页面没了请求还继续乱跑的问题

13.2 Repository 中处理 IO

kotlin 复制代码
class UserRepository(
    private val api: UserApi,
    private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun loadUser(): User = withContext(ioDispatcher) {
        api.getUser()
    }
}

这是一种很常见且清晰的分层方式:

  • ViewModel 负责 orchestration
  • Repository 负责数据访问和线程选择

13.3 多个请求并发加载

ini 复制代码
suspend fun loadHomeData(): HomeData = coroutineScope {
    val bannerDeferred = async { repository.loadBanner() }
    val feedDeferred = async { repository.loadFeed() }
    val profileDeferred = async { repository.loadProfile() }

    HomeData(
        banner = bannerDeferred.await(),
        feed = feedDeferred.await(),
        profile = profileDeferred.await()
    )
}

这里是典型适合 async 的场景:

多个独立请求并发执行,再汇总结果。


13.4 首页局部失败不影响整体

ini 复制代码
suspend fun loadHomeWidgets(): HomeWidgets = supervisorScope {
    val banner = async { runCatching { repository.loadBanner() } }
    val feed = async { runCatching { repository.loadFeed() } }
    val profile = async { runCatching { repository.loadProfile() } }

    HomeWidgets(
        banner = banner.await().getOrNull(),
        feed = feed.await().getOrNull(),
        profile = profile.await().getOrNull()
    )
}

这里用 supervisor 思想更合适。

因为首页一个卡片失败,不应该让所有内容都失败。


十四、服务端实战:协程不是只给 Android 用的

在 Kotlin 服务端里,协程同样非常强大。

适用场景:

  • HTTP 请求处理
  • 数据库访问
  • 调用其他服务
  • 并发聚合多个下游结果

比如一次请求要聚合三个下游接口:

ini 复制代码
suspend fun aggregate(userId: String): AggregateResult = coroutineScope {
    val profile = async { profileService.getProfile(userId) }
    val orders = async { orderService.getOrders(userId) }
    val coupon = async { couponService.getCoupon(userId) }

    AggregateResult(
        profile = profile.await(),
        orders = orders.await(),
        coupon = coupon.await()
    )
}

这种写法的优势很明显:

  • 逻辑顺序清晰
  • 并发天然表达
  • 取消可传递
  • 请求结束可统一取消子任务

如果客户端断开、请求超时,整棵协程树可以一起结束,这比传统散乱线程模型优雅得多。


十五、Flow:为什么它几乎总和协程一起出现

协程解决的是"单次异步任务"的组织问题。

那如果你处理的是"持续产生值的数据流"呢?

这就是 Flow


15.1 单值 vs 多值

  • suspend fun:返回 0 或 1 个结果
  • Flow<T>:可以连续发出多个值

例如:

kotlin 复制代码
fun ticker(): Flow<Int> = flow {
    var i = 0
    while (true) {
        emit(i++)
        delay(1000)
    }
}

15.2 为什么 Flow 和协程天然契合

因为 Flow 的发射和收集都建立在协程挂起机制之上。

你可以理解为:

  • 协程处理"一个异步过程"
  • Flow 处理"一个异步序列"

15.3 Android 中的常见用法

rust 复制代码
viewModelScope.launch {
    repository.userFlow().collect { user ->
        // 更新 UI
    }
}

或者配合 StateFlow 管理 UI 状态。


十六、StateFlow / SharedFlow:现代 Android 开发几乎绕不开


16.1 StateFlow

适合表示"当前状态"。

特点:

  • 永远有一个当前值
  • 收集者能立刻拿到最新值
  • 很适合 UI state
kotlin 复制代码
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState

更新:

ini 复制代码
_uiState.value = _uiState.value.copy(loading = true)

16.2 SharedFlow

适合表示"广播事件流"或多播数据。

比如:

  • toast 事件
  • 一次性导航事件
  • 消息推送分发

但实战里要小心"一次性事件"的设计,不要只因为有 SharedFlow 就乱发事件。


十七、把协程真的讲透:你需要建立的 8 个脑内模型

这是全文最关键的一节。

如果你没有这些模型,API 记再多都容易乱。


模型 1:协程是任务,不是线程

协程描述的是"要做的事情",线程是"谁来执行"。

不要把它们混为一谈。


模型 2:suspend 表示"可挂起",不是"自动异步"

suspend 没有神奇地把代码丢到后台,它只是允许函数暂停和恢复。


模型 3:挂起不等于阻塞

delay() 挂起协程,不阻塞线程。
Thread.sleep() 阻塞线程。


模型 4:scope 决定任务归属

协程必须属于一个作用域。

作用域不清,生命周期就会乱。


模型 5:Job 是生命周期树

父协程、子协程、取消传播,本质上是一棵任务树。


模型 6:默认失败传播是"同生共死"

普通 coroutineScope 里,一个子任务失败,整个 scope 失败。

这不是缺点,是默认安全策略。


模型 7:取消是协作式的

不是强杀。

需要挂起点或主动检查。


模型 8:并发要显式表达,不要顺手就开协程

该串行就串行,该并发才并发。

不要把"用了协程"误解成"所有东西都要并发"。


十八、实战中最常见的错误用法

这一节非常重要,很多坑都在这里。


18.1 用 GlobalScope 到处乱飞

坏处前面说过:生命周期失控。

大多数业务代码避免使用。


18.2 把 suspend 当"自动后台线程"

kotlin 复制代码
suspend fun queryDb() = dao.query()

如果 dao.query() 是阻塞操作,而你在 Main 线程调用它,就可能卡 UI。
suspend 不会自动帮你切到 IO。


18.3 过度 withContext

上下文切换不是越多越安全。

层层嵌套只会让代码难读。


18.4 不理解取消,吞掉 CancellationException

这是非常高频的问题。

一旦把取消吃掉,你会发现协程"怎么死不了"。


18.5 用 async 但不 await

csharp 复制代码
async {
    api.getUser()
}

这往往是有问题的。

你既没有管理结果,也可能让异常行为变得隐蔽。


18.6 为了"高级"强行并发

有些逻辑天然有依赖关系,就不该并发。

并发不是越多越快,可能更复杂、更难控、更浪费资源。


18.7 在错误的层次处理异常

不要每一层都 try-catch 一遍。

要明确:

  • 哪层负责转换为业务错误
  • 哪层负责更新 UI 状态
  • 哪层只透传

十九、如何设计一套真正可维护的协程代码结构

这里给你一套很实用的思路。


19.1 表现层负责"发起和收集状态"

比如 ViewModel:

  • 启动协程
  • 更新 loading / success / error 状态
  • 处理页面生命周期

19.2 领域层负责"组织业务流程"

比如 UseCase:

  • 串行 / 并发组合多个 repository
  • 控制 supervisor 还是普通 scope
  • 汇总结果、做业务规则判断

19.3 数据层负责"访问数据源和线程切换"

Repository:

  • 网络请求
  • 数据库读取
  • 缓存策略
  • 必要时选择 dispatcher

19.4 一个示例结构

kotlin 复制代码
class LoadHomeUseCase(
    private val repository: HomeRepository
) {
    suspend operator fun invoke(): HomeUiData = supervisorScope {
        val banner = async { runCatching { repository.loadBanner() } }
        val feed = async { runCatching { repository.loadFeed() } }
        val user = async { runCatching { repository.loadUser() } }

        HomeUiData(
            banner = banner.await().getOrNull(),
            feed = feed.await().getOrDefault(emptyList()),
            user = user.await().getOrNull()
        )
    }
}
ini 复制代码
class HomeViewModel(
    private val loadHomeUseCase: LoadHomeUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState

    fun load() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(loading = true)
            try {
                val data = loadHomeUseCase()
                _uiState.value = _uiState.value.copy(
                    loading = false,
                    data = data,
                    error = null
                )
            } catch (e: Exception) {
                _uiState.value = _uiState.value.copy(
                    loading = false,
                    error = e.message
                )
            }
        }
    }
}

这个结构的优点是:

  • 层次清晰
  • 生命周期清晰
  • 并发逻辑集中
  • UI 更新逻辑集中
  • 易测试

二十、协程测试:为什么你的测试老是不稳定

协程测试如果还按传统阻塞思路写,容易不稳定。

现代 Kotlin 协程测试常用:

  • runTest
  • TestDispatcher
  • StandardTestDispatcher
  • 虚拟时间推进

基本思路是:

  • 不依赖真实时间
  • 控制调度
  • 可预测执行顺序

例如:

kotlin 复制代码
@Test
fun testLoadUser() = runTest {
    val repository = FakeRepository()
    val result = repository.loadUser()
    assertEquals("Tom", result.name)
}

如果代码里用到了注入的 dispatcher,可以在测试中替换成测试 dispatcher,这样更可控。

实战原则就一句:

不要把真实线程和真实时间硬编码进业务代码。


二十一、性能和调度:协程快,但不是无脑快

协程常被宣传成"高性能",这没错,但要有边界感。


21.1 协程适合大量等待型任务

如果任务大量时间花在等待 IO、等待网络、等待锁,那么协程非常合适。


21.2 CPU 密集任务不是"开更多协程就更快"

CPU 密集任务受限于核心数。

你开 1000 个协程,不会让 8 核机器突然变成 1000 核。

这种场景里更重要的是:

  • 合理切到 Dispatchers.Default
  • 控制并发度
  • 避免上下文切换过多

21.3 阻塞代码和协程混用要小心

如果底层库是阻塞式的,你仍然可以在协程里调用,但应该放到合适 dispatcher,通常是 IO。

协程不是魔法,它不能把阻塞库自动变成非阻塞库。


二十二、什么时候该用协程,什么时候别硬上

适合协程的场景:

  • 异步请求
  • 并发聚合
  • 生命周期明确的任务
  • 可取消任务
  • UI 状态管理
  • 流式数据处理

不必强行上协程的场景:

  • 特别简单的同步逻辑
  • 没有异步边界的纯本地计算
  • 团队对协程模型完全不熟、且当前项目复杂度很低时

但现代 Kotlin 项目里,只要涉及网络、数据库、UI、服务端并发,协程基本已经是主流方案。


二十三、一个完整实战案例:用户主页加载

我们把前面零散的知识串起来。

目标:

  • 页面打开时加载用户资料、动态列表、推荐内容
  • 三个接口并发
  • 某个模块失败不影响其他模块
  • 页面退出时自动取消
  • UI 可观察 loading / data / error 状态

23.1 Repository

kotlin 复制代码
class ProfileRepository(
    private val api: ProfileApi,
    private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun loadProfile(): Profile = withContext(ioDispatcher) {
        api.loadProfile()
    }

    suspend fun loadPosts(): List<Post> = withContext(ioDispatcher) {
        api.loadPosts()
    }

    suspend fun loadRecommendations(): List<Item> = withContext(ioDispatcher) {
        api.loadRecommendations()
    }
}

23.2 UseCase

kotlin 复制代码
data class ProfilePageData(
    val profile: Profile?,
    val posts: List<Post>,
    val recommendations: List<Item>
)

class LoadProfilePageUseCase(
    private val repository: ProfileRepository
) {
    suspend operator fun invoke(): ProfilePageData = supervisorScope {
        val profileDeferred = async { runCatching { repository.loadProfile() } }
        val postsDeferred = async { runCatching { repository.loadPosts() } }
        val recDeferred = async { runCatching { repository.loadRecommendations() } }

        ProfilePageData(
            profile = profileDeferred.await().getOrNull(),
            posts = postsDeferred.await().getOrDefault(emptyList()),
            recommendations = recDeferred.await().getOrDefault(emptyList())
        )
    }
}

这里为什么用 supervisorScope

因为这三个模块相对独立。

用户资料失败,不该让动态和推荐也跟着消失。


23.3 ViewModel

kotlin 复制代码
data class ProfileUiState(
    val loading: Boolean = false,
    val data: ProfilePageData? = null,
    val error: String? = null
)

class ProfileViewModel(
    private val loadProfilePageUseCase: LoadProfilePageUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState: StateFlow<ProfileUiState> = _uiState

    fun load() {
        viewModelScope.launch {
            _uiState.value = ProfileUiState(loading = true)
            try {
                val data = loadProfilePageUseCase()
                _uiState.value = ProfileUiState(
                    loading = false,
                    data = data,
                    error = null
                )
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _uiState.value = ProfileUiState(
                    loading = false,
                    error = e.message ?: "unknown error"
                )
            }
        }
    }
}

这里为什么还要 catch?

因为虽然子模块失败被 runCatching 收住了,但 UseCase 自身仍可能有别的异常。

同时我们刻意不吞掉 CancellationException


23.4 UI 层收集状态

scss 复制代码
lifecycleScope.launch {
    viewModel.uiState.collect { state ->
        when {
            state.loading -> showLoading()
            state.error != null -> showError(state.error)
            state.data != null -> showData(state.data)
        }
    }
}

整条链路就完整了:

  • UI 收集状态
  • ViewModel 管理生命周期
  • UseCase 组织并发
  • Repository 管理 IO

这就是协程在实战里最舒服的使用方式。


二十四、把所有关键 API 串成一句人话

你可以这样记这些 API。

  • suspend:这个函数可以挂起
  • launch:启动一个任务,不直接要结果
  • async:启动一个任务,将来要结果
  • await:等待 async 的结果
  • delay:非阻塞等待
  • withContext:切换上下文执行并返回结果
  • coroutineScope:创建一个同生共死的子任务作用域
  • supervisorScope:创建一个失败隔离的子任务作用域
  • Job:任务生命周期
  • Dispatcher:调度到哪类线程
  • Flow:异步数据流
  • StateFlow:有当前值的状态流
  • SharedFlow:多播事件流

二十五、学习协程时最容易卡住的三个心理误区


误区 1:我只要会写 API 就算会协程

不是。

真正会协程的人,最强的是:

  • 能设计任务边界
  • 能设计作用域
  • 能设计取消和异常传播
  • 能控制并发结构

误区 2:协程是为了"更快"

不完全是。

协程首先是为了更清晰地表达并发和异步逻辑,其次才是性能和资源利用。


误区 3:协程就是 launch + async + delay

这只是表层。

真正决定代码质量的是:

  • 结构化并发
  • 生命周期管理
  • 上下文与线程模型
  • 取消机制
  • 错误边界
  • 状态流设计

二十六、最后总结:真正懂协程的人,脑子里装的是什么

如果你读到这里,应该能形成这样一套认知:

协程不是线程,而是轻量任务。
suspend 不是异步,而是可挂起。

挂起不是阻塞。

协程的价值不只是"写起来像同步",更是"任务有结构、能取消、能传播异常、生命周期可管理"。

写协程代码时,你真正要思考的是:

  1. 这个任务属于哪个作用域?
  2. 需要结果还是只需要执行?
  3. 该串行还是并发?
  4. 子任务失败是否要拖垮整体?
  5. 取消是否能正确传播?
  6. 哪一层负责切线程?
  7. 哪一层负责处理异常?
  8. 这是单次结果,还是持续数据流?

当你开始用这些问题去看代码时,你就已经从"会用 API"走向"真的懂协程"了。


二十七、给初学者的实践路线

如果你想真正掌握协程,建议按这个顺序练习:

先练会:

  • suspend
  • launch
  • async/await
  • withContext
  • delay

再重点练:

  • coroutineScope
  • supervisorScope
  • Job
  • 取消
  • 异常传播

最后进阶:

  • Flow
  • StateFlow
  • SharedFlow
  • 测试
  • 分层架构中的协程设计

二十八、一句话收尾

协程最厉害的地方,不是让异步代码"看起来像同步",而是让并发逻辑第一次真正有了清晰、可维护、可取消、可组合的结构。

这才是 Kotlin 协程的灵魂。

相关推荐
mldong2 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排3 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰5 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi6 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen7 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒8 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端