Kotlin 协程基础:简化异步编程入门 – wiki基地


Kotlin 协程基础:简化异步编程入门

异步编程是现代软件开发中不可或缺的一部分。无论是进行网络请求、访问数据库、处理大量数据,还是构建响应迅速的用户界面,我们都需要在执行耗时任务的同时,不阻塞主线程或用户界面。然而,传统的异步编程方式往往伴随着复杂性和挑战,例如回调地狱、线程管理的开销、状态管理的混乱等。

Kotlin 协程(Coroutines)正是为了解决这些问题而诞生的。它提供了一种全新且更加直观的方式来编写异步、非阻塞代码,让异步逻辑看起来就像同步代码一样简单。本文将深入浅出地介绍 Kotlin 协程的基础知识,帮助初学者轻松迈入异步编程的大门。

1. 为什么需要异步编程?传统方式的困境

在深入协程之前,我们先回顾一下为什么需要异步编程以及传统方式带来了哪些问题。

同步编程的局限性:

想象一个简单的场景:你的应用程序需要从服务器下载一张图片,然后显示在界面上。

“`kotlin
fun downloadImage(): Image {
// 模拟耗时的网络下载
println(“开始下载图片…”)
Thread.sleep(5000) // 阻塞 5 秒
println(“图片下载完成.”)
return Image(“downloaded_image.png”) // 假设返回一个 Image 对象
}

fun displayImage(image: Image) {
println(“显示图片: ${image.name}”)
}

fun main() {
println(“应用启动…”)
val image = downloadImage() // 阻塞!主线程在这里等待 5 秒
displayImage(image)
println(“应用结束.”)
}
“`

在上面的同步代码中,downloadImage() 函数会完全阻塞调用它的线程(在这里是 main 线程)长达 5 秒。如果在图形用户界面(GUI)应用中,比如 Android 应用的主线程上执行这样的操作,整个应用都会冻结,用户界面无响应,直到下载完成。这显然是不可接受的。

异步编程的必要性:

为了避免阻塞,我们需要让耗时操作在后台进行,同时主线程可以继续处理其他任务(比如响应用户的点击、更新UI)。这就是异步编程的核心思想:发起一个任务,然后立即返回,当任务完成后通过某种机制通知你结果。

传统异步编程的挑战:

  1. 回调(Callbacks): 最常见的异步模式。发起一个任务,提供一个回调函数,任务完成后调用这个回调函数。

    “`kotlin
    // 伪代码示例
    fun downloadImageAsync(callback: (Image) -> Unit) {
    // 在后台线程执行下载…
    backgroundThread {
    val image = downloadImageBlocking() // 仍然是后台线程的阻塞下载
    uiThread {
    callback(image) // 回调到主线程更新UI
    }
    }
    }

    // 使用时:
    println(“应用启动…”)
    downloadImageAsync { image ->
    displayImage(image)
    println(“应用结束.”)
    }
    // 主线程不会阻塞在这里,会继续向下执行,”应用结束”可能先打印出来
    println(“主线程正在做其他事情…”)
    “`

    问题: 当存在多个相互依赖的异步操作时,回调会层层嵌套,形成臭名昭著的“回调地狱”(Callback Hell),代码变得难以阅读、维护和调试。错误处理和取消操作也变得复杂。

  2. 多线程(Threads): 直接创建和管理线程。

    kotlin
    fun main() {
    println("应用启动...")
    val imageThread = Thread {
    val image = downloadImage() // 在新线程中阻塞
    // 如何将结果传回主线程更新UI?需要 Handler 或其他同步机制
    println("图片下载在新线程完成")
    }
    imageThread.start()
    println("主线程正在做其他事情...")
    // imageThread.join() // 如果要等待,又回到了阻塞
    println("应用结束.") // 可能先打印,图片还没显示
    }

    问题: 线程是操作系统级别的资源,创建和切换开销较大。管理大量线程会消耗大量内存。线程间的通信和同步(比如确保在主线程更新UI)需要锁、同步块等复杂机制,容易引入死锁、竞态条件等并发问题。取消一个正在运行的线程也很麻烦且不安全。

  3. Futures/Promises: 提供一种表示未来结果的对象。

    “`kotlin
    // 伪代码示例 (类似 Java 的 Future 或其他语言的 Promise)
    fun downloadImageFuture(): Future {
    // 返回一个 Future,代表未来的结果
    return backgroundTask {
    downloadImageBlocking()
    }
    }

    // 使用时:
    println(“应用启动…”)
    val futureImage = downloadImageFuture()
    println(“主线程正在做其他事情…”)
    // … 后面某个时候需要结果时:
    try {
    val image = futureImage.get() // 阻塞!直到结果可用
    displayImage(image)
    } catch (e: Exception) {
    // 错误处理
    }
    println(“应用结束.”)
    “`

    问题: get() 方法仍然是阻塞的。虽然有些库提供了链式调用的 thenApply/thenCompose 等非阻塞方法,但链式调用长了同样会降低可读性,并且在更复杂的控制流(如条件判断、循环)中,表达起来不如同步代码直观。

这些传统方式都有各自的局限性,使得异步代码往往比同步代码更难编写、理解和维护。Kotlin 协程的出现,正是为了提供一个更优雅、更强大的替代方案。

2. Kotlin 协程是什么?

简单来说,Kotlin 协程是一种轻量级的线程。但这个说法需要精确理解。协程不是操作系统级别的线程,它们是在用户空间(User Space)由 Kotlin 运行时管理的。

核心概念:

  1. 轻量级(Lightweight): 创建一个协程的开销非常小,几乎可以忽略不计。你可以在一个应用程序中轻松创建成千上万个协程,而创建同样数量的线程则会迅速耗尽系统资源。这是因为协程不拥有自己的操作系统线程栈,它们共用或调度到底层的少量线程上。
  2. 可暂停和可恢复(Suspendable & Resumable): 这是协程最关键的特性。一个协程可以在执行到某个“暂停点”(suspension point)时暂停执行,而不会阻塞它所在的线程。之后,当等待的条件满足时(比如网络请求返回数据),它可以从暂停的地方恢复执行。
  3. 协作式(Cooperative): 协程不像线程那样依赖操作系统的抢占式调度。它们是协作式的,意味着一个协程必须主动或在遇到协程库定义的暂停点时,才会暂停并将执行权让给其他协程。

协程与线程的关系:

协程运行在线程之上。协程本身并不执行代码,它们需要被调度到线程上执行。一个线程可以运行多个协程,而一个协程也可以在不同的时间点在不同的线程上恢复执行。协程库负责高效地将协程调度到合适的线程池中执行。

通过这种方式,协程将“计算”(你写的代码逻辑)与“执行”(底层的线程)解耦。你可以像写同步代码一样表达复杂的异步逻辑,而协程库负责高效地管理底层的线程资源。

3. suspend 关键字:协程的基石

在 Kotlin 协程中,suspend 关键字是核心。它用于标记一个函数是可暂停的

“`kotlin
suspend fun fetchData(): String {
println(“开始获取数据…”)
delay(2000) // 这是一个挂起点,会暂停当前协程,但不阻塞线程
println(“数据获取完成.”)
return “一些远程数据”
}

fun processData(data: String) {
println(“处理数据: $data”)
}

// suspend 函数只能在其他 suspend 函数或协程中调用
suspend fun mainCorouine() {
println(“应用启动…”)
val data = fetchData() // 调用 suspend 函数
processData(data)
println(“应用结束.”)
}
“`

suspend 的含义:

  • suspend 函数表示这个函数可能会暂停协程的执行。
  • 不代表这个函数会自动在后台线程执行。它只是一种标记,告诉编译器这个函数内部或它调用的其他 suspend 函数可能会发生暂停。
  • suspend 函数只能在另一个 suspend 函数或在一个协程构建器(如 launch, async, runBlocking)内部调用。这是一种编译器的强制约束,确保暂停点总是在协程的上下文中。

在上面的例子中,delay(2000) 是一个协程库提供的 suspend 函数,它会暂停当前协程 2 秒钟。当协程暂停时,它所在的线程可以去做其他事情,而不是被阻塞在那里。2 秒后,协程会从 delay 的下一行代码恢复执行。

4. 协程构建器(Coroutine Builders):启动协程

suspend 函数定义了协程可以暂停的地方,但它们本身不会启动一个新的异步任务。要启动一个协程,我们需要使用协程构建器

协程构建器是用于在现有协程作用域或全局作用域中创建一个新协程的函数。最常用的协程构建器有 runBlocking, launch, 和 async

4.1 runBlocking:连接阻塞与非阻塞世界

runBlocking 是一个特殊的协程构建器。它会运行一个新的协程,并阻塞当前调用它的线程,直到协程执行完毕。

“`kotlin
fun main() = runBlocking { // this: CoroutineScope
println(“应用启动…”)
val data = fetchData() // 在 runBlocking 启动的协程中调用 suspend 函数
processData(data)
println(“应用结束.”)
}

suspend fun fetchData(): String {
println(“协程 ${Thread.currentThread().name} 开始获取数据…”)
delay(2000)
println(“协程 ${Thread.currentThread().name} 数据获取完成.”)
return “一些远程数据”
}
“`

用途:

  • 主要用于连接非协程的阻塞代码(如 main 函数、单元测试)与协程代码。
  • 在单元测试中,runBlocking 非常有用,因为它可以让你像测试同步代码一样测试 suspend 函数。

注意:

  • runBlocking 会阻塞调用它的线程,因此不应该在协程内部或在可能阻塞UI的主线程上使用它,除非你有意为之(比如在 main 函数中启动整个协程应用)。在实际异步应用中,应尽量避免使用 runBlocking

4.2 launch:启动一个异步任务(不关心结果)

launch 用于启动一个协程来执行一个任务,但它不返回任何结果值。它常用于“发起并忘记”(fire-and-forget)的任务,或者只需要处理副作用( side effects ),而不需要返回特定结果的任务。

launch 返回一个 Job 对象,这个对象代表了协程的生命周期,可以用来取消协程或等待协程完成。

“`kotlin
import kotlinx.coroutines.* // 导入协程相关库

fun main() = runBlocking { // runBlocking 创建一个协程作用域
println(“应用启动…”)

val job: Job = launch { // 启动一个新协程
    fetchDataAndProcess() // 在这个新协程中执行任务
}

println("主协程正在做其他事情...") // 这行会立即执行,不会等待 launch 完成

job.join() // 等待 launch 启动的协程完成

println("应用结束.")

}

suspend fun fetchDataAndProcess() {
println(“子协程 ${Thread.currentThread().name} 开始…”)
delay(2000) // 暂停 2 秒
val data = “获取到的数据”
println(“子协程 ${Thread.currentThread().name} 处理数据: $data”)
println(“子协程 ${Thread.currentThread().name} 结束.”)
}
“`

在上面的例子中,launch 创建了一个新的协程。runBlocking 所在的协程不会等待 launch 协程,除非我们显式调用 job.join()

4.3 async:启动一个带结果的异步任务

async 用于启动一个协程来执行一个任务,并期望它能返回一个结果值。

async 返回一个 Deferred<T> 对象,它是 Job 的一个子类,代表一个将来会产生 T 类型结果的协程。你可以通过调用 deferred.await() 来获取结果。await() 方法是一个 suspend 函数,它会暂停当前协程,直到 async 协程计算出结果。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“应用启动…”)

val deferredData: Deferred<String> = async { // 启动一个新协程,期望返回 String
    fetchDataAsync() // 在这个新协程中执行任务
}

println("主协程正在做其他事情...") // 这行会立即执行

// await() 是一个 suspend 函数,会暂停当前协程直到 deferredData 有结果
val data = deferredData.await()

processData(data)

println("应用结束.")

}

suspend fun fetchDataAsync(): String {
println(“子协程(async) ${Thread.currentThread().name} 开始获取数据…”)
delay(2000)
println(“子协程(async) ${Thread.currentThread().name} 数据获取完成.”)
return “来自 async 的远程数据”
}

fun processData(data: String) {
println(“处理数据: $data”)
}
“`

launch vs async

  • 使用 launch 当你只需要执行一个任务(副作用)而不需要返回结果时。
  • 使用 async 当你需要异步计算一个值,并在之后某个点使用这个值时。

并发执行示例:

async 的一个强大用途是并行执行多个异步任务,然后等待它们全部完成并获取结果。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“应用启动…”)

val deferredData1 = async { fetchData1() } // 启动任务1
val deferredData2 = async { fetchData2() } // 启动任务2

println("两个任务已启动,主协程继续...")

// await() 会在各自的任务完成后返回,但这里是并行等待
val data1 = deferredData1.await()
val data2 = deferredData2.await()

println("所有数据获取完成:")
println("数据1: $data1")
println("数据2: $data2")

println("应用结束.")

}

suspend fun fetchData1(): String {
delay(3000) // 模拟耗时 3 秒
return “数据A”
}

suspend fun fetchData2(): String {
delay(2000) // 模拟耗时 2 秒
return “数据B”
}
“`

在这个例子中,fetchData1fetchData2 会几乎同时开始执行。await() 调用会等待各自的结果,但因为它们是并行运行的,总的执行时间大约是两个中最长的那个任务的时间(约 3 秒),而不是它们的总和(3+2=5 秒)。

5. Coroutine Scope (协程作用域):结构化并发

在前面的例子中,我们使用了 runBlocking 来启动协程。runBlocking 本身提供了一个 CoroutineScope。但是,在实际应用中,我们通常不会使用 runBlocking,而是需要在某个特定的生命周期(比如一个Activity、一个ViewModel、一个Presenter、一个业务逻辑单元)内管理协程。

CoroutineScope 的主要作用是:

  1. 管理协程的生命周期: 一个作用域可以启动多个协程,并提供一种方式来取消作用域内的所有协程。
  2. 实现结构化并发(Structured Concurrency): 协程可以形成父子关系。在一个作用域内启动的协程自动成为该作用域的子协程。当父协程(或作用域)被取消时,其所有子协程也会被取消。这极大地简化了并发任务的取消和错误处理。

你可以创建自己的 CoroutineScope 实例:

“`kotlin
import kotlinx.coroutines.*

// 创建一个 CoroutineScope,指定一个 Dispatcher (执行器) 和 Job (用于管理生命周期)
// 通常不需要手动创建顶层 Job,Dispatchers.Default 或 Dispatchers.Main 会提供
private val myScope = CoroutineScope(Dispatchers.Default) // 或 Dispatchers.Main

suspend fun performBackgroundTask() {
println(“在后台作用域启动任务…”)
myScope.launch { // 在 myScope 作用域中启动一个子协程
println(“子协程 ${Thread.currentThread().name} 开始执行…”)
delay(4000)
println(“子协程 ${Thread.currentThread().name} 执行完成。”)
}
// myScope.cancel() // 可以在某个时刻取消作用域及其所有子协程
}

fun main() = runBlocking {
println(“主协程开始…”)
performBackgroundTask() // 启动后台任务,但不会等待它完成
println(“主协程继续,子协程可能仍在后台运行…”)
delay(5000) // 给后台任务一些时间执行
println(“主协程结束.”)
// 注意:这里 runBlocking 结束,但 myScope 启动的协程可能还在运行,因为 myScope 没有被 cancel 或 join
// 在实际应用中,你需要根据组件生命周期管理 scope 的取消
}
“`

在 Android 开发中,Kotlin 协程库提供了与组件生命周期集成的内置作用域,例如 lifecycleScopeviewModelScope,极大地简化了在特定组件销毁时自动取消协程的工作。

结构化并发的优势在于:当一个父任务失败或被取消时,相关的子任务也会自动被清理,避免了资源泄露或不必要的计算。

6. Coroutine Context (协程上下文):在哪里以及如何运行?

每个协程都有一个相关的 CoroutineContext。上下文是一个元素的集合,这些元素定义了协程运行的方方面面,例如:

  • Job 控制协程的生命周期(启动、停止、取消)以及父子关系。
  • CoroutineDispatcher 决定协程在哪个线程或线程池上执行。
  • CoroutineName 用于调试,给协程起个名字。
  • CoroutineExceptionHandler 处理未捕获的异常(主要用于 launch 启动的顶层协程)。

通常,我们最常打交道的是 CoroutineDispatcher

6.1 CoroutineDispatcher (协程调度器):决定执行线程

协程调度器决定了协程在哪个线程或线程池中执行。Kotlin 协程提供了几种内置的调度器:

  • Dispatchers.Default 用于执行 CPU 密集型任务。它使用一个共享的线程池,线程数量通常等于 CPU 的核心数。适用于排序、计算、图像处理等任务。
  • Dispatchers.IO 用于执行 I/O 密集型任务,如网络请求、文件读写、数据库操作。它使用一个按需增长的线程池(上限通常为 64 或系统限制),适合处理大量潜在阻塞的 I/O 操作。
  • Dispatchers.Main 仅在支持主线程(UI 线程)调度的平台可用(如 Android, Swing, JavaFX)。协程在这个调度器上执行时,可以在主线程更新UI。注意: 这个调度器是平台相关的,需要在相应的依赖中获取(如 kotlinx-coroutines-android)。
  • Dispatchers.Unconfined 协程在这个调度器上启动时,会在当前线程立即执行,直到第一个暂停点。暂停后恢复时,会在恢复它的那个线程上继续执行。这个调度器不绑定到任何特定的线程池,通常不适合普通应用代码,主要用于测试或一些特殊场景。

你可以通过协程构建器的参数或 withContext 函数来指定调度器。

示例:使用 Dispatchers 切换线程

“`kotlin
import kotlinx.coroutines.*

suspend fun fetchUserData(): String {
println(“获取用户数据 – 在 ${Thread.currentThread().name} 上执行”)
delay(1000) // 模拟网络请求
return “用户数据”
}

suspend fun processUserData(data: String): String {
println(“处理用户数据 – 在 ${Thread.currentThread().name} 上执行”)
delay(1000) // 模拟 CPU 密集计算
return “处理后的 $data”
}

suspend fun updateUI(processedData: String) {
// 在 Android 等平台,Dispatchers.Main 是 UI 线程
println(“更新 UI – 在 ${Thread.currentThread().name} 上执行,数据显示: $processedData”)
// 实际 UI 更新代码
}

fun main() = runBlocking {
// 默认情况下,runBlocking 的协程在调用它的线程上执行 (这里是 main 线程)
println(“主协程开始 – 在 ${Thread.currentThread().name} 上执行”)

// 切换到 IO 调度器执行网络请求
val userData = withContext(Dispatchers.IO) {
    fetchUserData()
}

// 切换到 Default 调度器执行 CPU 密集计算
val processedData = withContext(Dispatchers.Default) {
    processUserData(userData)
}

// 在支持 UI 的平台上,切换到 Main 调度器更新 UI
// For JVM non-UI, this part might just run on the current thread or throw exception if Main is not available
// Assuming a UI environment for demonstration:
// withContext(Dispatchers.Main) {
//     updateUI(processedData)
// }
// For this main example (JVM), we'll just print
updateUI(processedData)


println("主协程结束 - 在 ${Thread.currentThread().name} 上执行")

}
“`

withContext 函数是协程中切换调度器的主要方式。它是一个 suspend 函数,会暂停当前协程,将协程的执行转移到指定的调度器上运行其中的代码块,代码块执行完毕后,协程会恢复,并可能切换回原来的调度器(取决于上下文的组合)。withContext 会等待代码块执行完成并返回结果。

使用 withContext 可以在同一个 suspend 函数内部方便地在不同的调度器之间切换,让你的函数既可以包含耗时 I/O 操作,又可以包含 CPU 密集计算,同时还能在需要时切回主线程更新UI,而代码仍然保持线性同步风格。

7. 取消 (Cancellation):停止不必要的任务

在异步编程中,取消正在执行的任务是一个非常重要的方面,可以节省资源、避免bug。例如,用户离开了正在加载数据的屏幕,我们就应该取消数据加载任务。

Kotlin 协程的取消是协作式的。这意味着协程不会在任意时刻被强制终止。它必须在执行到可取消的暂停点时检查取消状态,或者主动检查 isActive 属性。协程库中的所有标准暂停函数(如 delay, withContext, await, I/O 操作等)都是可取消的。

当一个协程被取消时:

  1. 它的 Job 状态会变为 Cancelling
  2. 如果协程当前正在某个可取消的暂停点等待,它会立即抛出一个 CancellationException
  3. 如果协程正在执行计算任务(不在暂停点),它需要主动检查取消状态或在下一个暂停点才会响应取消。

示例:取消一个任务

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“主协程启动…”)

val job = launch {
    repeat(5) { i ->
        try {
            println("子协程执行 $i ...")
            delay(500) // 这是一个可取消的暂停点
        } catch (e: CancellationException) {
            println("子协程被取消了!")
            // 清理资源等
            throw e // 重新抛出 CancellationException
        }
    }
    println("子协程正常完成。")
}

delay(1200) // 等待一段时间
println("时间到,取消子协程!")
job.cancel() // 请求取消
job.join() // 等待子协程结束 (被取消或清理完成)

println("主协程结束.")

}
“`

输出可能像这样:

主协程启动...
子协程执行 0 ...
子协程执行 1 ...
子协程执行 2 ...
时间到,取消子协程!
子协程被取消了!
主协程结束.

delay(500) 遇到取消请求时,它会立即停止等待并抛出 CancellationException。协程会捕获这个异常,执行 catch 块中的清理代码,然后通常会重新抛出异常,标记自己已通过异常终止。

主动检查取消状态:

如果你的协程正在执行一个长时间的计算循环,没有调用任何暂停函数,你可以主动检查 isActive 属性来判断是否需要停止。

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = launch(Dispatchers.Default) { // 在 Default 调度器上进行计算
var i = 0
// isActive 是 CoroutineScope 或 Job 上的一个属性
while (i < 1000_000 && isActive) { // 检查是否处于活跃状态 (未被取消)
// 执行一些计算
if (i % 100_000 == 0) {
println(“计算中: $i”)
}
i++
}
if (isActive) {
println(“计算完成。”)
} else {
println(“计算被取消在 $i。”)
}
}

delay(100) // 等待一段时间
println("主协程:取消计算任务")
job.cancelAndJoin() // 取消并等待完成

println("主协程结束。")

}
“`

在这个例子中,尽管没有 delay,子协程也会在大约 100 毫秒后检查到 isActive 变为 false,从而退出循环,响应取消。

结构化并发使得取消更加容易:取消一个父协程会自动递归地取消其所有子协程。这极大地简化了复杂的并发任务的取消逻辑。

8. 异常处理 (Exception Handling):优雅地处理错误

异步编程中的错误处理也常常是一个痛点。协程提供了一些机制来处理异常。

异常传播:

协程中的异常会沿着协程的父子层次结构传播。

  • 对于使用 launch 启动的协程,如果它抛出异常,并且没有设置 CoroutineExceptionHandler,异常会向上冒泡,并可能导致父协程和整个作用域被取消。这符合结构化并发中“子协程失败导致父协程失败”的原则。
  • 对于使用 async 启动的协程,抛出的异常会在调用 await() 时重新抛出。这使得你可以在 await() 调用的地方使用标准的 try/catch 块来捕获异常。

示例:launch 的异常处理

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
// 定义一个异常处理器
val handler = CoroutineExceptionHandler { _, exception ->
println(“捕获到未处理的异常: $exception”)
}

val scope = CoroutineScope(Dispatchers.Default + handler) // 将处理器添加到作用域的上下文

val job = scope.launch { // 在带有异常处理器的作用域中启动
    println("子协程开始...")
    delay(100)
    throw RuntimeException("子协程发生错误!") // 抛出异常
    println("子协程结束 (不会执行到)。")
}

job.join() // 等待子协程结束(因异常而终止)

println("主协程结束.")
scope.cancel() // 取消作用域

}
“`

CoroutineExceptionHandler 只能捕获由 launch 启动的顶层协程中未被捕获的异常。对于在子协程内部的 try/catch 块中捕获的异常,不会触发 CoroutineExceptionHandler

示例:async 的异常处理

“`kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
println(“主协程开始…”)

val deferred = async {
    println("async 子协程开始...")
    delay(100)
    throw IllegalArgumentException("async 中发生错误!") // 抛出异常
    println("async 子协程结束 (不会执行到)。")
    "结果" // 不会返回
}

try {
    val result = deferred.await() // 在这里调用 await() 时会抛出异常
    println("async 结果: $result") // 不会执行到
} catch (e: IllegalArgumentException) {
    println("在 await() 处捕获到异常: $e")
}

println("主协程结束.")

}
“`

使用 async 时,异常会在 await() 调用时显式地重新抛出,这使得异常处理与同步代码类似,使用 try/catch 块即可。

理解协程的异常传播规则以及何时使用 CoroutineExceptionHandler 是编写健壮协程代码的关键。

9. 实践应用示例 (以伪代码模拟UI场景)

让我们用一个更贴近实际应用(比如 Android)的伪代码例子,结合前面学到的知识:

“`kotlin
import kotlinx.coroutines.*

// 假设这是你的 UI 界面或 Presenter 类
class MyScreen {

// 创建一个协程作用域,通常与组件生命周期绑定
// 在 Android 中可能是 lifecycleScope 或 viewModelScope
private val uiScope = CoroutineScope(Dispatchers.Main) // 假设 Dispatchers.Main 可用

fun onCreate() {
    println("屏幕创建,开始加载数据...")
    loadData()
}

fun loadData() {
    uiScope.launch { // 在 UI 协程作用域启动一个新任务
        showLoading(true) // 在主线程更新 UI

        val result = try {
            // withContext 切换到 IO 调度器执行耗时操作
            val data = withContext(Dispatchers.IO) {
                fetchDataFromNetwork() // 模拟网络请求 (suspend 函数)
            }
            processFetchedData(data) // 可能在主线程处理数据或再次切换
        } catch (e: Exception) {
            handleError(e) // 在主线程处理错误
            null // 表示加载失败
        } finally {
            showLoading(false) // 无论成功失败,都在主线程隐藏加载指示器
        }

        result?.let {
            displayData(it) // 如果成功,在主线程显示数据
        }
    }
}

// 当屏幕销毁时,取消作用域,从而取消所有相关的协程
fun onDestroy() {
    println("屏幕销毁,取消所有协程...")
    uiScope.cancel() // 取消作用域及其所有子协程
}

// 模拟 suspend 函数
suspend fun fetchDataFromNetwork(): String {
    println("-> [${Thread.currentThread().name}] 开始网络请求...")
    delay(3000) // 模拟网络延迟
    // 模拟可能发生的网络错误
    // if (System.currentTimeMillis() % 2 == 0) {
    //     throw IOException("网络不稳定")
    // }
    println("-> [${Thread.currentThread().name}] 网络请求完成。")
    return "{\"user\":\"Alice\", \"data\":[1,2,3]}"
}

fun processFetchedData(jsonData: String): String {
     println("-> [${Thread.currentThread().name}] 处理数据: $jsonData")
     // 假设这里只是简单返回
     return "已处理数据: $jsonData"
}

// 以下函数都在 UI 线程执行
fun showLoading(isLoading: Boolean) {
    println("-> [${Thread.currentThread().name}] 显示加载状态: $isLoading")
}

fun displayData(data: String) {
    println("-> [${Thread.currentThread().name}] 显示数据: $data")
}

fun handleError(e: Exception) {
    println("-> [${Thread.currentThread().name}] 错误处理: ${e.message}")
    // 显示错误信息给用户
}

}

// 模拟应用运行生命周期
fun main() = runBlocking { // 使用 runBlocking 模拟主线程环境
val screen = MyScreen()
screen.onCreate()

// 模拟用户在数据加载完成前离开屏幕
delay(1000) // 等待 1 秒
println("模拟用户离开屏幕...")
screen.onDestroy()

// 实际的网络请求可能还需要一些时间,但协程已经被取消
// 在这个 runBlocking 结束前,会等待所有活动协程,但 uiScope 已经被取消,
// 所以 launch 的子协程会因为 delay(3000) 在 1 秒后检测到取消而终止
delay(4000) // 再等待 4 秒,确保取消生效并观察输出
println("模拟应用主循环结束。")

}
“`

在这个例子中:

  1. MyScreen 类持有一个 uiScope,其调度器是 Dispatchers.Main,这意味着在这个作用域内直接启动的协程默认运行在主线程。
  2. loadData 函数在 uiScope.launch 中启动一个新协程。
  3. showLoading 在主线程调用,因为 launch 默认在 uiScopeMain 调度器上运行。
  4. fetchDataFromNetwork 是一个耗时操作,通过 withContext(Dispatchers.IO) 将执行切换到 IO 线程池,避免阻塞主线程。
  5. processFetchedData 可以在 IO 线程或切换回主线程执行,取决于任务性质和需要在哪个线程进行。这里简化为在 IO 线程执行。
  6. handleErrordisplayData 需要更新 UI,因此它们需要在主线程执行。由于 withContext(Dispatchers.IO) {...} 块执行完毕后,协程会自动切回调用它的调度器 (Dispatchers.Main 在这个例子中),所以 processFetchedData 之后的代码以及 catchfinally 块的代码都会在 Dispatchers.Main 上执行,可以直接安全地更新UI。
  7. try/catch/finally 块用于优雅地处理数据加载过程中的错误和清理(隐藏加载指示器)。
  8. onDestroy 调用 uiScope.cancel(),利用结构化并发的特性,取消 uiScope 中所有未完成的子协程,包括正在进行的网络请求。

这个例子展示了协程如何使得在不同线程之间切换、执行异步任务、处理结果、更新UI以及处理取消和异常变得简单而直观,代码的可读性远高于传统的回调或手动线程管理方式。

10. 进阶话题 (了解方向)

本文重点介绍了 Kotlin 协程的基础,但协程的世界远不止这些。如果你想深入学习,可以进一步了解:

  • Flow: 用于处理异步数据流,非常适合表示随时间产生多个值的场景(如数据库观察者、网络请求流)。
  • Channels: 协程之间通信的一种方式,可以在不同协程之间安全地传递数据。
  • Select Expression: 用于等待多个异步操作中第一个完成的那个。
  • 协程的测试: 如何编写可测试的 suspend 函数和协程代码。
  • 协程的底层实现: 深入理解状态机、Continuation 等概念。

11. 总结

Kotlin 协程为异步编程带来了革命性的简化。通过 suspend 关键字、协程构建器 (launch, async, runBlocking)、结构化的协程作用域 (CoroutineScope) 和灵活的协程上下文 (CoroutineContext, Dispatchers),开发者可以:

  • 以接近同步代码的方式编写复杂的异步逻辑。
  • 轻松地在不同的线程(如 IO 线程和 UI 线程)之间切换。
  • 通过结构化并发简化任务的取消和错误处理。
  • 利用轻量级特性启动大量并发任务而无需担心资源开销。

虽然本文涵盖了协程的基础,异步编程和并发仍然是复杂的领域。理解其背后的原理和最佳实践至关重要。但 Kotlin 协程无疑极大地降低了异步编程的入门门槛和开发难度,是现代 Kotlin 开发中不可或缺的工具。

希望这篇文章能帮助你迈出 Kotlin 协程的第一步,体验它带来的便利和强大!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部