第6章:协程入门

一个厨师同时做五道菜的秘密 — 不需要更多的人,只需要更聪明的等待

🔬

费曼 (Richard Feynman)

物理直觉,并发就像同时做菜

你见过一个厨师同时做五道菜吗?他不会傻等着水烧开——他会在等水烧开的时候去切菜,在等菜炖烂的时候去调酱汁。他只有一双手(一个线程),但他同时在"处理"五道菜。这就是协程的本质:不是增加更多的厨师,而是让一个厨师更聪明地利用等待时间。

如果你理解了"等待不等于空闲"这个道理,你就理解了协程的全部哲学。

6.1 为什么需要协程

一个物理学家眼中的异步问题

想象你在做一个物理实验。你点燃了酒精灯,需要等水加热到 100 度。在这段时间里,你会怎么做?

一个笨办法:死盯着温度计——你站在那里一动不动,什么都不做,直到水烧开。这就是阻塞式编程(blocking)。在 Android 中,如果你在主线程(UI 线程)上做网络请求或数据库查询,就相当于死盯着温度计——界面卡住了,用户什么都点不了,系统甚至会弹出"应用无响应"(ANR)。

一个聪明的办法:你对助手说"水开了告诉我",然后你去做别的实验。但如果你有十个实验都在等待,每个都需要一个助手来喊你——这就是回调(Callback)模式。助手多了,沟通就乱了,这就是经典的回调地狱(Callback Hell)

// 回调地狱示例(伪代码)
fetchUser(userId) { user ->
    fetchOrders(user.id) { orders ->
        fetchOrderDetail(orders[0].id) { detail ->
            fetchProduct(detail.productId) { product ->
                // 终于拿到数据了...但代码已经嵌套了4层
                updateUI(product)
            }
        }
    }
}

最聪明的办法呢?你自己就是那个聪明的厨师。你不需要助手帮你盯着,你只需要设好计时器,到点了自己回来看一眼。在等待期间,你可以自由地做别的事——这就是协程的工作方式。

线程的代价:为什么不能多请几个厨师?

你可能会想:"多开几个线程不就行了?"这就像是说"多请几个厨师来帮忙"。听起来不错,但问题是:

协程:一个聪明厨师的解决方案

Kotlin 协程(Coroutines)是一种全新的并发方案。协程是轻量级的线程——更准确地说,它根本不是线程,它只是一段可以暂停恢复的代码。一个线程可以运行成千上万个协程,就像一个厨师可以同时管理几十道菜。

协程最妙的地方在于:你用同步的写法做异步的事。代码看起来是一行一行顺序执行的,但底层其实在你"等待"的时候偷偷去做了别的事。

C++ 对比:Kotlin 协程 vs C++20 Coroutines

C++20 也引入了协程,但它是非常底层的机制——你需要手动定义 promise_type、处理 co_await/co_yield/co_return、自己管理 std::futurestd::promise。这就像给你一堆零件让你自己组装发动机。Kotlin 协程则是更高层的抽象,编译器帮你处理了所有底层细节——你只需要写一个 suspend 关键字,剩下的自动搞定。就好比 C++ 给你一堆齿轮和螺丝,而 Kotlin 直接给你一辆车。

对比:回调 vs RxJava vs 协程

先看回调风格——就像每个实验都喊一个助手来帮你盯着:

// 回调风格 — 嵌套深,错误处理散落各处
fetchUser(userId, object : Callback<User> {
    override fun onSuccess(user: User) {
        fetchOrders(user.id, object : Callback<List<Order>> {
            override fun onSuccess(orders: List<Order>) { /* ... */ }
            override fun onError(e: Exception) { /* 又一处错误处理 */ }
        })
    }
    override fun onError(e: Exception) { /* 错误处理 */ }
})

再看 RxJava 风格——像一条流水线,但你得学会所有工位的操作手册:

// RxJava 风格 — 链式调用,但学习曲线陡峭
fetchUser(userId)
    .flatMap { user -> fetchOrders(user.id) }
    .flatMap { orders -> fetchOrderDetail(orders[0].id) }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(
        { detail -> updateUI(detail) },
        { error -> handleError(error) }
    )

最后看协程风格——就像一个厨师的工作笔记,一步步写得清清楚楚:

// 协程风格 — 像写同步代码一样简洁
suspend fun loadData(userId: String) {
    try {
        val user = fetchUser(userId)          // 挂起,等待结果
        val orders = fetchOrders(user.id)     // 挂起,等待结果
        val detail = fetchOrderDetail(orders[0].id)
        updateUI(detail)
    } catch (e: Exception) {
        handleError(e)                       // 统一错误处理
    }
}

费曼的直觉

协程代码看起来和同步代码几乎一样——就像厨师的菜谱上写着"第一步煮面、第二步炒菜",但实际上厨师在煮面的空档已经在切菜了。编译器会帮你把 suspend 函数转换为状态机,自动处理"放下这道菜、去做那道菜"的切换逻辑。

特性 回调 RxJava 协程
代码可读性 差(嵌套深) 中等(链式) 优秀(顺序)
错误处理 分散 统一(onError) 统一(try-catch)
学习曲线
取消支持 需手动实现 dispose() 内建支持
Kotlin 集成 一般 一般 原生支持

6.2 第一个协程

添加依赖

在做实验之前,我们先把实验器材准备好。在 build.gradle.kts(Module 级别)中添加协程依赖:

// build.gradle.kts (Module :app)
dependencies {
    // 协程核心库
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    // Android 协程支持(提供 Dispatchers.Main)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

runBlocking: 点燃第一个酒精灯

我们的第一个实验用 runBlocking——这是一个特殊的协程构建器,它会阻塞当前线程直到协程执行完毕。把它想象成"我要在这里做完这个实验才走"。它主要用于 main 函数和测试代码中,千万不要在 Android 的主线程使用,否则就回到"死盯温度计"了。

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("协程开始 — 线程: ${Thread.currentThread().name}")
    delay(1000)  // 挂起 1 秒(不阻塞线程)
    println("协程结束")
}
// 输出:
// 协程开始 — 线程: main
// (等待1秒)
// 协程结束

launch: 同时点燃多个酒精灯

launch 是最常用的协程构建器。它启动一个新的协程但不返回结果——就像你点燃了一个酒精灯然后转身去做别的事。它返回一个 Job 对象,你可以用它来管理协程(比如取消它,或者等它完成)。

看看下面的例子,注意输出顺序——这是理解协程的关键时刻:

fun main() = runBlocking {
    println("主协程开始")

    launch {
        delay(1000)
        println("子协程 1 完成")
    }

    launch {
        delay(500)
        println("子协程 2 完成")
    }

    println("主协程继续执行(不会等待子协程)")
}
// 输出:
// 主协程开始
// 主协程继续执行(不会等待子协程)
// 子协程 2 完成    ← 500ms 后
// 子协程 1 完成    ← 1000ms 后

看到了吗?"主协程继续执行"先打印出来了!这就像厨师点燃两个炉灶后,立刻去做其他事了,不会傻等其中任何一个。子协程 2 先完成,因为它只需要等 500 毫秒——哪道菜先好就先出哪道。

C++ 对比:Thread vs Coroutine

C++ 的 std::thread 是真正的操作系统线程——每创建一个就像新雇一个厨师,占用真实的系统资源(约 1MB 栈内存)。Kotlin 协程则是用户态的轻量级任务,一个线程可以运行上万个协程。打个比方:C++ 的做法是一道菜请一个厨师(开一个线程),而 Kotlin 的做法是一个厨师管所有菜(一个线程跑多个协程)。后者的内存开销几乎可以忽略不计。

delay vs Thread.sleep:暂停实验 vs 关掉实验室

这个区别至关重要。delay() 是"暂停当前实验,去做别的";Thread.sleep() 是"锁上实验室的门,谁都别想进来"。

对比 delay() Thread.sleep()
是否阻塞线程 不阻塞(挂起协程) 阻塞整个线程
其他协程能否运行 可以 不可以(线程被占用)
使用场景 协程中 普通线程代码
可取消

注意

永远不要在协程中使用 Thread.sleep()!这就像厨师在等水烧开的时候把厨房门锁了——其他菜全部停摆。在协程中应该使用 delay(),这样线程可以在你"等待"的时候去服务其他协程。

6.3 suspend 函数

暂停一个实验,去检查另一个

挂起函数(suspend function)是协程的核心概念。用物理学家的话说:suspend 意味着"这个实验需要等待一段时间,我先去看看隔壁那个实验进展如何"。

suspend 关键字修饰的函数可以暂停执行并在稍后恢复执行,且不会阻塞线程。你可以把 suspend 理解为一个标记,告诉编译器:"这个函数里有需要等待的操作——网络请求、数据库查询、文件读取——别让厨师干等着。"

suspend 关键字

// 用 suspend 关键字声明挂起函数
suspend fun fetchUserFromNetwork(userId: String): User {
    delay(2000)  // 模拟网络延迟
    return User(userId, "张三")
}

// 挂起函数只能在协程或另一个挂起函数中调用
suspend fun loadUserData() {
    val user = fetchUserFromNetwork("001")  // OK: 在 suspend 函数中调用
    println("用户: ${user.name}")
}

fun normalFunction() {
    // val user = fetchUserFromNetwork("001")  // 编译错误!普通函数不能调用 suspend 函数
}

只能在协程或其他 suspend 函数中调用

这是 Kotlin 编译器的强制规则——就像实验室的安全规范一样不可违背。为什么?因为挂起操作需要一个"协程上下文"来管理暂停和恢复。在普通函数中调用 suspend 函数,就像在没有实验台的地方做化学实验——编译器会果断阻止你。

费曼的类比

挂起函数就像一个需要等待的物理实验:你把样品放进离心机(开始网络请求),设好时间(等待响应),然后转身去写论文(线程执行其他协程)。离心机转完后,实验室系统会通知你"样品好了"(协程恢复执行)。你不需要站在离心机旁边干等——这就是非阻塞的含义。

示例:模拟网络请求

import kotlinx.coroutines.*

// 数据类
data class User(val id: String, val name: String)

// 模拟网络请求的挂起函数
suspend fun fetchUser(id: String): User {
    println("  开始请求用户 $id ...")
    delay(1500)  // 模拟 1.5 秒网络延迟
    println("  用户 $id 请求完成")
    return User(id, "用户$id")
}

fun main() = runBlocking {
    println("开始加载数据...")
    val user = fetchUser("42")
    println("加载完成: ${user.name}")
}
// 输出:
// 开始加载数据...
//   开始请求用户 42 ...
//   用户 42 请求完成
// 加载完成: 用户42

6.4 协程构建器

协程构建器就是你"启动实验"的方式。不同的构建器适用于不同的场景——有的实验你只要它做完就行(launch),有的实验你需要它的结果(async)。

launch: 点火就走(Fire and Forget)

launch 启动一个新协程,不返回结果。它就像你点燃了一个酒精灯加热某个东西——你不需要从这个操作得到什么"返回值",你只需要它默默完成。它返回一个 Job 对象,让你可以管理这个"实验进程"。

fun main() = runBlocking {
    val job: Job = launch {
        println("协程正在执行...")
        delay(1000)
        println("协程执行完毕")
    }

    println("等待协程完成...")
    job.join()  // 等待协程执行完毕
    println("全部完成")
}

async/await: 做实验并拿回结果

asynclaunch 类似,但它会返回一个 Deferred<T> 对象——这就像一张"实验结果取件单"。你可以在需要结果的时候调用 await() 来取走它。如果结果还没准备好,await() 会挂起等待(但不阻塞线程)。

fun main() = runBlocking {
    val deferred: Deferred<Int> = async {
        println("正在计算...")
        delay(1000)
        42  // 返回值
    }

    println("等待结果...")
    val result = deferred.await()  // 挂起直到结果准备好
    println("结果是: $result")
}

并发执行:同时炒三道菜

这是 async 最强大的用法——同时发起多个异步操作,等所有结果都准备好后再一起端上桌。这就是真正的"并发":不是一道菜做完再做下一道,而是三个锅同时开火。

suspend fun fetchUserProfile(): String {
    delay(1000)  // 模拟 1 秒
    return "用户资料"
}

suspend fun fetchUserOrders(): String {
    delay(1200)  // 模拟 1.2 秒
    return "订单列表"
}

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()

    // 串行执行:总共需要 2.2 秒(一道菜做完再做下一道)
    // val profile = fetchUserProfile()
    // val orders = fetchUserOrders()

    // 并发执行:只需要约 1.2 秒(两道菜同时做,取最慢的)
    val profileDeferred = async { fetchUserProfile() }
    val ordersDeferred = async { fetchUserOrders() }

    val profile = profileDeferred.await()
    val orders = ordersDeferred.await()

    val elapsed = System.currentTimeMillis() - startTime
    println("$profile + $orders, 耗时: ${elapsed}ms")
}
// 输出: 用户资料 + 订单列表, 耗时: ~1200ms

费曼的直觉

两个 async 同时启动,总耗时取决于最慢的那道菜,而非两者之和。这就像物理实验中的并行测量——同时测温度和压力,总时间是 max(测温时间, 测压时间),而不是两者相加。这就是并发的威力。

withContext: 换个实验室继续做

withContext 用来在协程中切换执行的线程。想象你在化学实验室做分析,但需要用到物理实验室的仪器——你端着样品走过去(切换线程),用完仪器后再走回来(切回原线程)。

suspend fun loadDataFromDisk(): String {
    return withContext(Dispatchers.IO) {
        // 这里运行在 IO 线程池
        println("读取磁盘 — 线程: ${Thread.currentThread().name}")
        delay(500)
        "磁盘数据"
    }
    // 执行完自动切回原来的线程
}

fun main() = runBlocking {
    println("主线程: ${Thread.currentThread().name}")
    val data = loadDataFromDisk()
    println("回到主线程: ${Thread.currentThread().name}, 数据: $data")
}

6.5 协程作用域与结构化并发

CoroutineScope: 实验室的管理制度

CoroutineScope 定义了协程的生命周期范围。在物理实验中,你不会让一个实验无人看管地一直运行——实验室关门了,所有实验都必须安全地停下来。CoroutineScope 就是这个"实验室管理制度":作用域结束时,其中所有未完成的协程都会被取消。

fun main() = runBlocking {
    // runBlocking 自身就是一个 CoroutineScope

    launch {
        delay(200)
        println("子协程 A")
    }

    coroutineScope {  // 创建一个新的作用域
        launch {
            delay(500)
            println("子协程 B(在 coroutineScope 内)")
        }
        println("coroutineScope 开始")
    }  // coroutineScope 会等待内部所有协程完成

    println("所有都完成了")
}

结构化并发:实验室的安全规范

Kotlin 协程的一大设计原则是结构化并发(Structured Concurrency)。这就像一个严格的实验室管理规范:

fun main() = runBlocking {
    val parentJob = launch {
        val childA = launch {
            repeat(5) { i ->
                println("子协程 A: $i")
                delay(300)
            }
        }

        val childB = launch {
            repeat(5) { i ->
                println("子协程 B: $i")
                delay(300)
            }
        }
    }

    delay(800)
    println("取消父协程")
    parentJob.cancel()  // 子协程 A 和 B 都会被取消
    println("父协程已取消")
}

C++ 对比:结构化并发

C++ 没有结构化并发的概念。当你用 std::thread 创建线程后,你必须手动调用 join()detach()——如果忘了,程序直接崩溃(std::terminate)。这就像一个没有管理制度的实验室,每个实验做完了你得自己记着去收拾,忘了就出事故。Kotlin 的 CoroutineScope 自动管理所有子协程的生命周期:作用域结束时自动取消、自动等待,不需要你手动 join 每一个。安全、省心。

GlobalScope 的陷阱:失控的实验

不推荐使用 GlobalScope

GlobalScope 启动的协程是顶级协程,不受任何父作用域管理。这就像在实验室外面的走廊上做实验——没人管你,也没人在实验室关门时提醒你停下。后果是:

// 不推荐
GlobalScope.launch {
    // 这个协程的生命周期与应用进程一样长
    // Activity 销毁后它还在跑 — 内存泄漏!
    fetchData()
}

// 推荐:使用与生命周期绑定的 scope
class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            // ViewModel 清除时,协程自动取消
            fetchData()
        }
    }
}

Android 中的 viewModelScope 和 lifecycleScope

Android Jetpack 提供了与生命周期绑定的协程作用域——就像不同等级的实验室门禁,到点了自动关门断电:

作用域 所属组件 自动取消时机
viewModelScope ViewModel ViewModel 被清除时
lifecycleScope Activity / Fragment Lifecycle 进入 DESTROYED 状态时
// 在 ViewModel 中使用
class UserViewModel : ViewModel() {
    fun loadUser() {
        viewModelScope.launch {
            val user = repository.fetchUser()
            _userState.value = user
        }
    }
}

// 在 Activity 中使用
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            val data = loadSomething()
            textView.text = data
        }
    }
}

依赖

要使用 viewModelScopelifecycleScope,需要添加对应的 Jetpack 依赖:

6.6 调度器(Dispatchers)

调度器决定了协程在哪个线程或线程池上执行。用厨房的比喻来说,调度器就是"在哪个灶台上做菜"——炒菜要用猛火灶(CPU 密集),炖汤可以用小火慢炖灶(IO 操作),摆盘装饰要在出菜口完成(UI 线程)。选错灶台,不仅效率低下,还可能出安全问题。

调度器 用途 线程
Dispatchers.Main 更新 UI、处理用户交互 Android 主线程
Dispatchers.IO 网络请求、文件读写、数据库操作 共享的 IO 线程池(最多 64 个线程)
Dispatchers.Default CPU 密集型计算(排序、解析 JSON 等) 共享线程池(线程数 = CPU 核心数)
Dispatchers.Unconfined 测试或特殊场景(不推荐日常使用) 不限定线程

C++ 对比:Dispatchers vs 线程池

Kotlin 的调度器概念类似于 C++ 中的线程池,比如 boost::asio::thread_pool 或 C++17 的 std::execution 提案。但 Kotlin 把这些封装得更简洁:你不需要手动创建线程池、管理任务队列、处理线程的生死——只需要指定 Dispatchers.IODispatchers.Default,框架帮你搞定一切。C++ 里面你得写一堆样板代码来设置线程池,而 Kotlin 里只是一个参数的事。

Dispatchers.Main: 出菜口(UI 线程)

在 Android 中,所有 UI 更新必须在主线程执行——这就像菜做好了必须在出菜口装盘。Dispatchers.Main 将协程调度到主线程。

viewModelScope.launch(Dispatchers.Main) {
    // 在主线程更新 UI
    progressBar.visibility = View.VISIBLE

    val data = withContext(Dispatchers.IO) {
        // 切换到 IO 线程做网络请求
        api.fetchData()
    }

    // 自动切回主线程
    textView.text = data
    progressBar.visibility = View.GONE
}

Dispatchers.IO: 慢炖灶(网络 / 磁盘 IO)

IO 操作(网络、文件、数据库)就像炖汤——大部分时间都在等待,不怎么占用厨师的精力。把这些任务交给 IO 调度器,主线程(主厨师)就不会被堵住。

suspend fun readFile(path: String): String {
    return withContext(Dispatchers.IO) {
        File(path).readText()
    }
}

Dispatchers.Default: 猛火灶(CPU 密集计算)

排序大数组、解析大型 JSON、图像处理——这些是需要厨师全力投入的"猛火爆炒"任务。Default 调度器的线程数等于 CPU 核心数,确保不会因为开太多线程反而拖慢系统。

suspend fun sortLargeList(list: List<Int>): List<Int> {
    return withContext(Dispatchers.Default) {
        list.sorted()  // CPU 密集计算
    }
}

withContext 切换调度器:一个完整的做菜流程

一个典型的 Android 数据加载流程——在出菜口(Main)开始准备,去后厨(IO)取原料,回到出菜口(Main)装盘:

class UserRepository {
    suspend fun getUser(id: String): User {
        return withContext(Dispatchers.IO) {
            // 在 IO 线程池执行网络请求
            api.fetchUser(id)
        }
    }
}

class UserViewModel(private val repo: UserRepository) : ViewModel() {
    fun loadUser(id: String) {
        viewModelScope.launch {  // 默认在 Main 线程
            _loading.value = true
            val user = repo.getUser(id)  // 内部已切换到 IO
            _user.value = user            // 回到 Main 线程更新 UI
            _loading.value = false
        }
    }
}

最佳实践

让 Repository 层的函数内部使用 withContext 切换调度器,这样调用方不需要关心线程切换——就像你点菜时不需要知道厨师用的是猛火灶还是慢炖灶,菜端上来就行。这被称为"main-safety"(主线程安全)。

6.7 实战:模拟异步数据加载

完整示例:一场精心安排的多道菜实验

下面是一个完整的可运行示例,综合运用了前面所有知识。把它想象成一个厨师先去市场买菜(获取用户列表),然后同时处理每道菜的配料(并发获取详情):

import kotlinx.coroutines.*

// ===== 数据模型 =====
data class User(
    val id: Int,
    val name: String,
    val email: String
)

// ===== 模拟网络 API =====
object FakeApi {

    suspend fun fetchUsers(): List<User> {
        println("[API] 正在请求用户列表...")
        delay(2000)  // 模拟 2 秒网络延迟
        return listOf(
            User(1, "张三", "zhangsan@example.com"),
            User(2, "李四", "lisi@example.com"),
            User(3, "王五", "wangwu@example.com")
        )
    }

    suspend fun fetchUserDetail(userId: Int): String {
        println("[API] 正在请求用户 $userId 的详情...")
        delay(1000)  // 模拟 1 秒延迟
        return "用户 $userId 的详细信息:VIP 会员,注册于 2024 年"
    }
}

// ===== 主程序 =====
fun main() = runBlocking {
    println("===== 开始加载数据 =====")
    val startTime = System.currentTimeMillis()

    // 第一步:获取用户列表
    val users = FakeApi.fetchUsers()
    println("[结果] 获取到 ${users.size} 个用户")

    // 第二步:并发获取每个用户的详情
    val details = users.map { user ->
        async {
            FakeApi.fetchUserDetail(user.id)
        }
    }.map { it.await() }  // 等待所有结果

    // 第三步:展示结果
    users.forEachIndexed { index, user ->
        println("  ${user.name} (${user.email})")
        println("    ${details[index]}")
    }

    val elapsed = System.currentTimeMillis() - startTime
    println("===== 加载完成,总耗时: ${elapsed}ms =====")
    // 总耗时约 3 秒(2秒列表 + 1秒详情并发),而非 5 秒(串行)
}
// 输出:
// ===== 开始加载数据 =====
// [API] 正在请求用户列表...
// [结果] 获取到 3 个用户
// [API] 正在请求用户 1 的详情...
// [API] 正在请求用户 2 的详情...
// [API] 正在请求用户 3 的详情...
//   张三 (zhangsan@example.com)
//     用户 1 的详细信息:VIP 会员,注册于 2024 年
//   李四 (lisi@example.com)
//     用户 2 的详细信息:VIP 会员,注册于 2024 年
//   王五 (wangwu@example.com)
//     用户 3 的详细信息:VIP 会员,注册于 2024 年
// ===== 加载完成,总耗时: ~3000ms =====

注意到了吗?三个用户详情的请求是同时发出的(三道菜同时下锅),所以总共只花了约 1 秒而不是 3 秒。加上之前获取列表的 2 秒,总共约 3 秒——比串行的 5 秒快了将近一半。这就是一个聪明厨师的效率。

错误处理:实验失败了怎么办

做实验难免有失败的时候。协程中的错误处理非常直观——直接使用 try-catch,和同步代码完全一样。不需要学新的错误处理机制,用你已经熟悉的方式就好。这就是"用同步的写法做异步的事"的另一个好处。

suspend fun riskyFetchUser(id: Int): User {
    delay(500)
    if (id < 0) {
        throw IllegalArgumentException("用户 ID 不能为负数: $id")
    }
    return User(id, "用户$id", "user$id@example.com")
}

fun main() = runBlocking {
    // 方式一:直接 try-catch
    try {
        val user = riskyFetchUser(-1)
        println("用户: ${user.name}")
    } catch (e: Exception) {
        println("出错了: ${e.message}")
    }

    // 方式二:对 async 使用 try-catch
    val deferred = async {
        riskyFetchUser(-1)
    }
    try {
        val user = deferred.await()  // 异常在 await() 时抛出
        println("用户: ${user.name}")
    } catch (e: Exception) {
        println("async 出错了: ${e.message}")
    }
}
// 输出:
// 出错了: 用户 ID 不能为负数: -1
// async 出错了: 用户 ID 不能为负数: -1

费曼的直觉

对于 launch,异常会直接在协程内"爆炸"(实验当场出事);对于 async,异常会延迟到 await() 调用时才"爆炸"(你去取实验结果时才发现出了问题)。两种情况都可以用 try-catch 这个"安全护具"来应对。

取消协程:紧急叫停实验

协程支持协作式取消——这就像实验室的紧急停止按钮,但它不是暴力断电(那会损坏仪器),而是在每个安全检查点(挂起点)通知协程"该停了"。调用 cancel() 后,协程会在下一个挂起点(如 delayyieldwithContext)检查取消状态并优雅地停止。

fun main() = runBlocking {
    val job = launch {
        repeat(10) { i ->
            println("正在处理: $i")
            delay(300)  // 这是一个挂起点,会检查取消状态
        }
    }

    delay(1000)
    println("等够了,取消协程!")
    job.cancel()   // 请求取消
    job.join()     // 等待取消完成
    // 也可以用 job.cancelAndJoin() 合并上面两步
    println("协程已取消")
}
// 输出:
// 正在处理: 0
// 正在处理: 1
// 正在处理: 2
// 正在处理: 3
// 等够了,取消协程!
// 协程已取消

注意:纯计算循环不会响应取消

如果协程中没有挂起点(比如纯 CPU 计算的循环),取消请求不会生效——就像你按了紧急停止按钮,但仪器上根本没装传感器。这时你需要手动检查取消状态:

val job = launch(Dispatchers.Default) {
    var i = 0
    while (isActive) {  // 手动检查协程是否仍在活跃
        i++
        // 纯 CPU 计算...
    }
    println("计算了 $i 次后被取消")
}

本章总结

让我们用厨房的比喻来回顾本章学到的一切:

费曼的总结

协程的核心思想其实很简单:等待不等于空闲。一个聪明的物理学家不会在等待实验结果时发呆,一个聪明的厨师不会在等水烧开时干站着。协程让你的代码也学会了这种智慧。在后续章节中,我们将结合 Retrofit、Room 等库,在实际的 Android 项目中用协程处理网络请求和数据库操作——到时候你会看到,这个聪明的厨师在真正的厨房里有多高效。

练习题

练习 1:编写你的第一个挂起函数 基础

编写一个挂起函数 brewCoffee(type: String): String,模拟冲泡咖啡的过程:打印"正在冲泡 {type} ...",延迟 1.5 秒后返回"{type} 已做好"。然后在 runBlocking 中依次调用它两次(分别传入"拿铁"和"美式"),打印返回的结果。

查看答案
import kotlinx.coroutines.*

suspend fun brewCoffee(type: String): String {
    println("正在冲泡 $type ...")
    delay(1500)
    return "$type 已做好"
}

fun main() = runBlocking {
    val coffee1 = brewCoffee("拿铁")
    println(coffee1)
    val coffee2 = brewCoffee("美式")
    println(coffee2)
}
// 输出:
// 正在冲泡 拿铁 ...
// 拿铁 已做好
// 正在冲泡 美式 ...
// 美式 已做好
// 总耗时约 3 秒(串行)

练习 2:用 launch 和 async 实现并发 进阶

改进练习 1:用 async 同时冲泡"拿铁"和"美式"两杯咖啡,并在两杯都做好后打印结果。另外再用 launch 启动一个"清洗咖啡机"的后台任务(延迟 500ms 后打印"咖啡机已清洗")。观察输出顺序,思考为什么"清洗咖啡机"在两杯咖啡之前完成。

查看答案
import kotlinx.coroutines.*

suspend fun brewCoffee(type: String): String {
    println("正在冲泡 $type ...")
    delay(1500)
    return "$type 已做好"
}

fun main() = runBlocking {
    // 后台任务:清洗咖啡机(不需要结果)
    launch {
        delay(500)
        println("咖啡机已清洗")
    }

    // 并发冲泡两杯咖啡(需要结果)
    val latte = async { brewCoffee("拿铁") }
    val americano = async { brewCoffee("美式") }

    println("${latte.await()} & ${americano.await()}")
}
// 输出:
// 正在冲泡 拿铁 ...
// 正在冲泡 美式 ...
// 咖啡机已清洗          ← 500ms 后
// 拿铁 已做好 & 美式 已做好  ← 1500ms 后
// 总耗时约 1.5 秒(并发),比串行的 3 秒快了一倍!

"清洗咖啡机"只需要 500ms 就完成了,而两杯咖啡各需要 1500ms。三个任务同时启动,谁先做完谁先输出——这就是并发的魅力。

练习 3:理解调度器的用途 进阶

为以下场景选择合适的调度器(Dispatchers.MainDispatchers.IODispatchers.Default),并说明理由:

  1. 从服务器下载一张图片
  2. 对下载的图片进行模糊处理(高斯模糊算法)
  3. 将处理后的图片显示在 ImageView
  4. 将处理后的图片保存到本地文件
查看答案
  1. Dispatchers.IO — 下载图片是网络 IO 操作,大部分时间在等待服务器响应
  2. Dispatchers.Default — 高斯模糊是 CPU 密集型计算,需要大量运算
  3. Dispatchers.Main — 更新 UI 必须在主线程执行,这是 Android 的铁律
  4. Dispatchers.IO — 写文件是磁盘 IO 操作,不应该在主线程执行

完整的代码流程大致如下:

viewModelScope.launch {
    // 1. IO 线程下载图片
    val rawImage = withContext(Dispatchers.IO) { downloadImage(url) }

    // 2. Default 线程做图像处理
    val blurred = withContext(Dispatchers.Default) { applyBlur(rawImage) }

    // 3. Main 线程更新 UI
    imageView.setImageBitmap(blurred)

    // 4. IO 线程保存文件
    withContext(Dispatchers.IO) { saveToFile(blurred, path) }
}

练习 4:结构化并发与取消 挑战

编写一个程序,模拟"厨师同时做三道菜,但客人突然退单了"的场景:

  1. runBlocking 中用 launch 启动一个父协程
  2. 在父协程中用三个 launch 启动三道菜的制作(分别延迟 2秒、3秒、4秒),每道菜开始时打印"开始做 {菜名}",完成时打印"{菜名} 做好了"
  3. 在主协程中等待 1.5 秒后取消父协程,模拟客人退单
  4. 观察输出,验证三道菜是否都被成功取消(没有打印"做好了")
查看答案
import kotlinx.coroutines.*

fun main() = runBlocking {
    val chef = launch {
        launch {
            println("开始做 宫保鸡丁")
            delay(2000)
            println("宫保鸡丁 做好了")
        }
        launch {
            println("开始做 红烧排骨")
            delay(3000)
            println("红烧排骨 做好了")
        }
        launch {
            println("开始做 清蒸鲈鱼")
            delay(4000)
            println("清蒸鲈鱼 做好了")
        }
    }

    delay(1500)
    println("客人退单了!取消所有菜品")
    chef.cancelAndJoin()
    println("所有菜品已取消,清理厨房")
}
// 输出:
// 开始做 宫保鸡丁
// 开始做 红烧排骨
// 开始做 清蒸鲈鱼
// 客人退单了!取消所有菜品
// 所有菜品已取消,清理厨房
// (注意:没有任何"做好了"被打印——结构化并发确保了所有子协程都被成功取消)

这就是结构化并发的威力:取消父协程,所有子协程自动被取消。不需要一个一个去停,不需要手动追踪哪些任务还在运行。实验室喊"紧急撤离",所有实验自动安全关停。

← 上一章 目录 下一章 →