Kotlin 协程:从入门到深度理解
很多人学 Kotlin 协程时,都会经历一个很奇怪的阶段:
一开始觉得它很简单:
"哦,suspend 就是异步。"
"launch 开个协程。"
"async 拿个结果。"
"withContext(Dispatchers.IO) 切线程。"
然后一上项目,立刻就乱了。
为什么这里要 suspend,那里不能加?
为什么 launch 里的异常有时会崩,有时又不崩?
为什么取消了页面,网络请求还在跑?
为什么有人说不要乱用 GlobalScope?
为什么 Flow 和协程关系这么近?
为什么大家都在讲"结构化并发"?
这篇文章的目标不是让你"知道协程 API 有哪些",而是让你真正建立一套脑内模型:
- 协程到底是什么
- 它和线程、回调、Future、Rx 的关系是什么
suspend到底意味着什么- 协程如何调度、如何取消、如何传播异常
- 实战里如何组织代码结构
- Android / 后端开发中协程到底应该怎么用
读完以后,你不但会写,还会知道为什么这么写。
一、先别急着写代码:协程到底解决什么问题
先看一个最原始的问题。
你有一段逻辑:
- 查用户信息
- 再查用户订单
- 再查订单详情
- 最后渲染页面
如果你用同步阻塞写法,大概是这样:
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。
这是协程世界里最重要的基础设施之一。
它通常包含这些东西:
JobDispatcherCoroutineNameCoroutineExceptionHandler- 其他自定义上下文元素
6.1 Job:生命周期和取消控制
Job 负责:
- 协程是否还活着
- 是否取消
- 父子关系
- 完成状态
示例:
scss
val job = launch {
delay(5000)
println("done")
}
delay(1000)
job.cancel()
这里 job.cancel() 会取消协程。
6.2 Dispatcher:决定协程在哪类线程运行
常见调度器:
Dispatchers.DefaultDispatchers.IODispatchers.MainDispatchers.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() }
// 某个失败,不一定拖垮全部
}
supervisorScope 和 coroutineScope 的区别很关键:
coroutineScope:一个孩子失败,兄弟通常都取消supervisorScope:孩子失败,不自动干掉兄弟
适用场景:
- 多个子任务相互独立
- 一个失败不影响其他结果
- 比如首页多个卡片并行加载,某个模块失败不应该让整个页面全挂
八、launch 和 async 不只是"有无返回值"这么简单
初学时大家常说:
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 父子协程异常传播
在普通结构化并发中:
- 子协程失败
- 父协程失败
- 兄弟协程被取消
这就是默认"同生共死"的策略。
如果你不想这样,就用 SupervisorJob 或 supervisorScope。
10.4 CoroutineExceptionHandler 的边界
很多人以为加了 CoroutineExceptionHandler 就万事大吉。
不是。
它主要对根协程未捕获异常 有效。
对子协程、对 async 的 await 异常,不是你想的那种"全能兜底器"。
实战里更可靠的思路是:
- 在合适的层级显式处理异常
- 区分业务异常、取消异常、系统异常
- 用 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 里典型是 viewModelScope、lifecycleScope。
请求级作用域
服务端一次 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 协程测试常用:
runTestTestDispatcherStandardTestDispatcher- 虚拟时间推进
基本思路是:
- 不依赖真实时间
- 控制调度
- 可预测执行顺序
例如:
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 不是异步,而是可挂起。
挂起不是阻塞。
协程的价值不只是"写起来像同步",更是"任务有结构、能取消、能传播异常、生命周期可管理"。
写协程代码时,你真正要思考的是:
- 这个任务属于哪个作用域?
- 需要结果还是只需要执行?
- 该串行还是并发?
- 子任务失败是否要拖垮整体?
- 取消是否能正确传播?
- 哪一层负责切线程?
- 哪一层负责处理异常?
- 这是单次结果,还是持续数据流?
当你开始用这些问题去看代码时,你就已经从"会用 API"走向"真的懂协程"了。
二十七、给初学者的实践路线
如果你想真正掌握协程,建议按这个顺序练习:
先练会:
suspendlaunchasync/awaitwithContextdelay
再重点练:
coroutineScopesupervisorScopeJob- 取消
- 异常传播
最后进阶:
FlowStateFlowSharedFlow- 测试
- 分层架构中的协程设计
二十八、一句话收尾
协程最厉害的地方,不是让异步代码"看起来像同步",而是让并发逻辑第一次真正有了清晰、可维护、可取消、可组合的结构。
这才是 Kotlin 协程的灵魂。