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 协程的灵魂。

相关推荐
Hilaku3 小时前
做中后台业务,为什么我不建议你用 Tailwind CSS?
前端·css·代码规范
有意义3 小时前
【面试复盘】前端底层原理与 React 核心机制深度梳理
前端·react.js·面试
二十一_3 小时前
LangChain 教程 05|模型配置:AI 的大脑与推理引擎
前端·langchain
kerli3 小时前
Compose 组件:BoxWithConstraints作用及其原理
android·前端
LovroMance3 小时前
消息总线 + 可插拔的消息插件管理系统
前端
Lee川3 小时前
React Router 实战指南:构建现代化前端路由系统
前端·react.js·架构
薛纪克3 小时前
前端自动化测试的 Spec 模式:用 Kiro Power 实现从需求到脚本的全链路自动化
前端·自动化测试·ai·kiro
YAwu113 小时前
HTML语义化渲染与CSS优先级机制的技术解析
前端
MgArcher3 小时前
Python高级特性:filter() 函数完全指南
前端·后端