Kotlin Coroutines 入门指南:告别线程和回调地狱,拥抱更简单的异步编程
前言:为什么我们需要协程?
在软件开发中,我们经常会遇到需要执行耗时操作的场景,例如网络请求、数据库访问、文件读写,或者复杂的计算。这些操作如果放在主线程(例如 Android 的 UI 线程)中执行,会导致界面卡顿甚至应用无响应(ANR – Application Not Responding)。因此,我们需要将这些耗时任务放到后台线程中执行。
传统的异步编程方式主要有两种:
- 使用线程(Threads):直接创建或使用线程池来执行任务。然而,线程是操作系统级别的资源,创建和销毁开销较大,数量过多容易消耗大量内存,导致上下文切换频繁,管理起来也比较复杂,容易引发竞态条件、死锁等问题。
- 使用回调(Callbacks):通过回调函数来处理异步操作的结果。例如,一个网络请求成功或失败后,会调用相应的回调函数。这种方式随着异步操作的增多和嵌套,容易形成臭名昭著的“回调地狱”(Callback Hell),代码难以阅读、维护和理解。
Kotlin 协程(Coroutines)应运而生,它提供了一种更简洁、高效且结构化的方式来处理异步和并发编程。协程是一种轻量级的、用户级的线程,它不是由操作系统调度,而是由协程库或框架在应用程序内部进行调度。这使得协程的创建和切换开销非常小,可以在一个线程中运行成千上万个协程,而不会耗尽系统资源。更重要的是,协程允许你用看起来像同步顺序执行的代码来编写异步逻辑,极大地提高了代码的可读性和可维护性。
本文将带你一步步入门 Kotlin 协程,理解其核心概念和基本用法。
第一站:引入协程库
在开始使用协程之前,你需要将协程库添加到你的项目中。如果你使用的是 Gradle,可以在 build.gradle
(app 模块或相应的模块)文件中添加以下依赖:
“`gradle
dependencies {
// 协程核心库
implementation(“org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3”) // 检查最新版本
// 如果你在 Android 开发中使用,还需要 UI 主线程调度器
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // 检查最新版本
}
“`
同步 Gradle 文件后,你就可以开始使用 Kotlin 协程了。
第二站:理解核心概念
协程的世界里有几个核心概念是必须要理解的:
- 挂起函数 (Suspend Functions)
- 协程作用域 (Coroutine Scope)
- 协程上下文 (Coroutine Context)
- 协程构建器 (Coroutine Builders)
- Job
- Dispatcher (调度器)
- 结构化并发 (Structured Concurrency)
让我们逐一深入了解。
1. 挂起函数 (Suspend Functions)
挂起函数是协程中最核心的概念之一。它们是使用 suspend
关键字修饰的函数,例如:
kotlin
suspend fun fetchData(): String {
// 模拟一个耗时操作,例如网络请求
kotlinx.coroutines.delay(1000) // delay 是一个挂起函数,不会阻塞线程
return "Data fetched!"
}
suspend
关键字表示这个函数可能会“暂停”(挂起)它的执行,并在稍后某个时刻“恢复”(恢复)执行。重点是:挂起并不会阻塞当前线程! 当一个挂起函数执行到需要等待结果的地方(例如 delay
或一个真正的网络请求),它会挂起当前的协程,让出执行权给线程上的其他任务。当等待的结果回来后,协程会从挂起的地方恢复执行。
重要的限制: 挂起函数只能在另一个挂起函数中调用,或者在协程中调用。你不能直接在一个普通的非挂起函数(例如 main
函数或 Android 的点击事件监听器)中调用一个挂起函数。
2. 协程作用域 (Coroutine Scope)
协程作用域定义了协程的生命周期。它负责追踪在其内部创建的所有协程。当一个作用域被取消时,它会取消其所有子协程。这是实现结构化并发的基础。
常见的协程作用域包括:
GlobalScope
: 全局作用域。在这个作用域中启动的协程的生命周期只受整个应用程序的生命周期限制。通常不推荐使用GlobalScope
,因为它不具备结构化并发的特性,难以追踪协程的生命周期和取消。- 通过协程构建器创建的作用域:
launch
和async
等构建器会在当前协程作用域中创建新的子协程,这些子协程会继承父协程的作用域。 - 自定义作用域:你可以创建自己的
CoroutineScope
实例,并使用它来管理一组相关的协程。例如,在 Android 开发中,你可以让一个CoroutineScope
的生命周期与一个 Activity 或 ViewModel 绑定,在 Activity/ViewModel 销毁时取消该 Scope,从而取消所有相关的协程,避免内存泄漏。
一个 CoroutineScope
实例通常与一个 CoroutineContext
关联。
3. 协程上下文 (Coroutine Context)
协程上下文是协程执行所需的一组元素。它是一个集合,包含以下重要元素:
Job
: 控制协程的生命周期(活跃、完成、取消等),并允许你取消或等待协程完成。CoroutineDispatcher
: 确定协程在哪个线程或线程池上执行。CoroutineName
: 协程的名称(可选,用于调试)。CoroutineExceptionHandler
: 处理协程中未捕获的异常(可选)。
当你使用协程构建器启动一个协程时,你可以指定一个协程上下文。如果没有指定,它会继承父协程的上下文。
4. 协程构建器 (Coroutine Builders)
协程构建器是用于启动协程的函数。最常用的构建器是:
launch
: 启动一个新协程,不阻塞当前线程,并返回一个Job
对象。通常用于执行“并发而不关心结果”的任务,或者执行副作用操作(如更新 UI)。async
: 启动一个新协程,不阻塞当前线程,并返回一个Deferred<T>
对象。Deferred
是一个特殊的Job
,它承诺将来会产生一个类型为T
的结果。你可以调用deferred.await()
来获取结果,await()
是一个挂起函数,会挂起当前协程直到结果可用。通常用于执行“并发并需要获取结果”的任务。runBlocking
: 启动一个新的协程,并阻塞当前线程直到协程完成。主要用于连接阻塞世界(如main
函数或单元测试)与协程世界。不应在生产环境的异步代码中(如 UI 线程)使用runBlocking
,因为它会阻塞线程。
5. Job
Job
是一个句柄,代表一个正在运行或将来会运行的协程。它允许你:
- 检查协程的状态(
isActive
,isCancelled
,isCompleted
)。 - 取消协程 (
cancel()
)。 - 等待协程完成 (
join()
)。join()
是一个挂起函数,会挂起当前协程直到该 Job 完成。
当使用 launch
启动协程时,它会返回一个 Job
。使用 async
启动协程时,它返回一个 Deferred
,而 Deferred
是 Job
的子接口。
6. Dispatcher (调度器)
调度器决定了协程在哪个线程上运行。Kotlin Coroutines 提供了几种标准的调度器:
Dispatchers.Default
: 默认调度器。适用于执行 CPU 密集型任务(如复杂的计算、排序)。它使用一个共享的后台线程池,线程数量通常等于 CPU 的核心数。Dispatchers.IO
: 适用于执行 I/O 密集型任务(如网络请求、文件读写、数据库操作)。它使用一个更大的线程池,线程数量可以根据需要增长。Dispatchers.Main
: 适用于需要在主线程(例如 Android 的 UI 线程)上执行的任务。通常用于更新 UI。使用这个调度器需要在你的依赖中加入kotlinx-coroutines-android
或其他平台特定的主线程调度器库。Dispatchers.Unconfined
: 非限制调度器。协程会在调用它的那个线程上启动,但当挂起恢复后,它可能在任意线程上恢复执行。这个调度器非常特殊,通常不用于日常开发,除非你确定你需要这种行为,并且理解其潜在的线程切换和栈溢出风险。初学者应避免使用它。
你可以通过在协程构建器中指定上下文来选择调度器,例如 launch(Dispatchers.IO) { ... }
。如果你不指定,通常会继承父协程的调度器,或者使用 Dispatchers.Default
作为默认值(取决于构建器和上下文)。
7. 结构化并发 (Structured Concurrency)
结构化并发是协程的一个重要特性,它使得异步编程更加安全和易于管理。其核心思想是:协程应该有一个明确的父子关系,并且父协程的生命周期与其所有子协程的生命周期绑定。
这意味着:
- 取消传播: 如果父协程被取消,它的所有子协程也会被递归地取消。
- 错误传播: 子协程中的未捕获异常会向上抛给父协程。
- 等待完成: 父协程会等待所有子协程完成才能最终完成自身的
Job
。
通过 CoroutineScope
和 Job
的层次结构,协程实现了结构化并发。当你在一个 CoroutineScope
中使用 launch
或 async
启动协程时,新的协程会成为该 Scope(或其关联的 Job)的子协程。这种层级关系使得你可以轻松地管理一组相关的并发任务,例如在一个屏幕销毁时取消所有在该屏幕上启动的网络请求。
第三站:动手实践 – 基本用法
现在我们来通过代码示例感受一下协程的基本用法。
首先,我们需要一个地方来启动协程。在 JVM 应用的 main
函数中,我们通常使用 runBlocking
作为入口点:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // runBlocking 协程构建器,阻塞主线程直到内部协程完成
println(“Main start: ${Thread.currentThread().name}”) // 打印当前线程
// 启动一个新协程
launch { // launch 协程构建器,在当前 runBlocking 的作用域下启动
println("Coroutine start: ${Thread.currentThread().name}") // 打印当前协程运行的线程
delay(1000) // 调用挂起函数 delay,非阻塞地等待 1 秒
println("Coroutine end: ${Thread.currentThread().name}") // 恢复执行
}
println("Main end: ${Thread.currentThread().name}") // 这行会先于 Coroutine end 打印
}
“`
运行上面的代码,你可能会看到类似以下的输出:
Main start: main
Main end: main
Coroutine start: main
Coroutine end: main
解释:
runBlocking
启动一个协程并在当前线程(这里是main
线程)上运行。launch
在runBlocking
的作用域内启动了一个新的协程。默认情况下,它会继承父协程的上下文,包括调度器。在runBlocking
中,默认调度器是在当前线程上运行的特殊调度器。println("Main end...")
在launch
协程启动后立即执行,因为它没有等待launch
协程完成。launch
协程执行到delay(1000)
时挂起,让出main
线程的执行权。main
函数继续执行,直到runBlocking
协程的作用域结束(等待所有子协程完成)。- 1秒后,
delay
结束,launch
协程恢复执行,打印 “Coroutine end…”。 runBlocking
等待其内部的launch
协程完成后,runBlocking
块结束,main
函数也随之结束。
如果你想在后台线程执行协程:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
println(“Main start: ${Thread.currentThread().name}”)
// 在 Default 调度器上启动协程
launch(Dispatchers.Default) {
println("Coroutine start: ${Thread.currentThread().name}") // 会是 DefaultDispatcher 线程
delay(1000)
println("Coroutine end: ${Thread.currentThread().name}")
}
println("Main end: ${Thread.currentThread().name}")
}
“`
输出:
Main start: main
Main end: main
Coroutine start: DefaultDispatcher-worker-1 // 线程名可能不同
Coroutine end: DefaultDispatcher-worker-1
解释:
这次我们在 launch
中明确指定了 Dispatchers.Default
。协程会在后台的 DefaultDispatcher 线程池中执行。main
线程不会被阻塞,Main end
会立即打印。runBlocking
仍然会等待后台的子协程完成后才结束。
使用 async
获取结果
如果一个协程需要计算并返回一个结果,应该使用 async
:
“`kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun doSomethingUsefulOne(): Int {
delay(1000) // 模拟耗时操作
return 13
}
suspend fun doSomethingUsefulTwo(): Int {
delay(1000) // 模拟耗时操作
return 29
}
fun main() = runBlocking {
val time = measureTimeMillis {
// 启动两个 async 协程,它们可以并发执行
val one = async { doSomethingUsefulOne() } // async 返回 Deferred
val two = async { doSomethingUsefulTwo() }
// await() 是挂起函数,等待结果。因为是并发启动,总时间大约是两者中最长的那个任务的时间
println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")
}
“`
输出:
The answer is 42
Completed in ~10xx ms // 大约 1 秒多,因为两个任务并发执行
解释:
- 我们定义了两个挂起函数
doSomethingUsefulOne
和doSomethingUsefulTwo
,它们模拟了耗时操作。 - 在
runBlocking
协程中,我们使用async
并发地启动了两个任务。async
立即返回一个Deferred
对象,而不会阻塞。 one.await()
和two.await()
分别获取两个任务的结果。await()
是一个挂起函数。当调用one.await()
时,如果任务一还没有完成,当前协程会挂起,直到任务一完成并返回结果。任务二同理。- 因为两个
async
任务几乎同时启动并在后台运行,所以总的执行时间接近于最长的那个任务的时间(这里都是 1 秒)。如果不是用协程或线程并发,而是顺序调用这两个挂起函数,总时间会是 2 秒。
第四站:结构化并发的体现:父子关系和取消
协程的结构化并发特性让管理并发任务变得更容易。看一个例子:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// 在 runBlocking 作用域下启动一个 Job
val job = launch {
repeat(1000) { i ->
println(“Coroutine $i …”)
delay(500) // 挂起,检查取消状态
}
}
delay(1500) // 主协程等待 1.5 秒
println("Main: I'm tired of waiting!")
job.cancel() // 取消子协程
job.join() // 等待子协程完全取消
println("Main: Now I can quit.")
}
“`
输出:
Coroutine 0 ...
Coroutine 1 ...
Coroutine 2 ...
Main: I'm tired of waiting!
Main: Now I can quit.
解释:
launch
启动的协程成为了runBlocking
协程的子协程。- 子协程开始循环打印并
delay
。delay
是一个可取消的挂起函数。 - 主协程等待 1.5 秒后,调用
job.cancel()
。 - 子协程在执行
delay(500)
时,会检查自身的Job
是否被取消。检测到取消信号后,delay
函数会抛出CancellationException
。 - 由于
CancellationException
是一个特殊的异常,协程框架会将其视为协程被取消的信号,而不是未处理的错误。协程会清理资源并终止执行。 job.join()
确保主协程会等待子协程完成取消过程。- 子协程取消后,主协程打印 “Main: Now I can quit.”,然后
runBlocking
完成。
重要的点: 协程的取消是协作式的。一个正在运行的协程(例如在执行一个没有调用任何挂起函数或不检查 isActive
的紧密循环)不会自动被取消。它必须通过调用可取消的挂起函数(如 delay
, yield
, 或标准库中支持协程的 IO/网络操作)来主动检查取消状态,或者显式地检查 isActive
属性。
如果你的协程内部有大量的计算而不调用挂起函数,并且你需要它可取消,你需要在适当的地方添加 yield()
或检查 isActive
:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) { // 使用 Default 调度器避免阻塞主线程
var nextPrintTime = startTime
var i = 0
while (isActive) { // 通过检查 isActive 属性使循环可取消
// 每隔 500ms 打印一次消息
if (System.currentTimeMillis() >= nextPrintTime) {
println(“Coroutine: I’m sleeping ${i++} …”)
nextPrintTime += 500
}
// 可以选择性地在这里添加 yield() 让出执行权
// yield()
}
// 或者通过调用挂起函数来实现协作取消点
// ensureActive() // 另一个检查 isActive 并抛出异常的便捷函数
}
delay(1300) // 等待一段时间
println(“Main: I’m tired of waiting!”)
job.cancelAndJoin() // 取消并等待完成
println(“Main: Now I can quit.”)
}
“`
第五站:调度器详解与切换
协程可以在不同的调度器之间切换,这使得在执行不同类型的任务时非常灵活。例如,你可能需要在后台线程执行网络请求,然后在主线程更新 UI。
withContext
是一个非常有用的挂起函数,它可以用来切换协程的上下文(包括调度器),并在块执行完成后恢复到原来的上下文。
“`kotlin
import kotlinx.coroutines.*
suspend fun fetchUserData(): String {
println(“Fetching user data on ${Thread.currentThread().name}”)
delay(1000) // 模拟网络请求
return “User Data”
}
suspend fun updateUI(data: String) {
println(“Updating UI with ‘$data’ on ${Thread.currentThread().name}”)
// 在 Android 中,这里应该是 Dispatchers.Main
}
fun main() = runBlocking {
println(“Main coroutine starts on ${Thread.currentThread().name}”)
val userData = withContext(Dispatchers.IO) { // 切换到 IO 调度器执行 I/O 操作
fetchUserData() // 这个函数会在 IO 线程池中执行
}
println("Back to original coroutine context on ${Thread.currentThread().name}")
// 假设这里是在 UI 环境,我们切换到 Main 调度器更新 UI
// 在 JVM 应用中我们没有 Dispatchers.Main,这里用 runBlocking 的上下文(main 线程)模拟
// 在 Android 中,你需要引入 kotlinx-coroutines-android 库来使用 Dispatchers.Main
withContext(Dispatchers.Default) { // 模拟切换到 UI 线程 (在 JVM main 函数中,Default 不会是 main 线程)
updateUI(userData) // 这个函数会在 Default 线程池中执行
}
println("Main coroutine ends on ${Thread.currentThread().name}")
}
“`
请注意: 在上面的 JVM 示例中,withContext(Dispatchers.Default)
实际上会切换到 DefaultDispatcher
的工作线程,而不是 main
线程。要在 Android 中切换到主线程,你需要引入 kotlinx-coroutines-android
库,并使用 Dispatchers.Main
。
一个 Android 环境下的 withContext(Dispatchers.Main)
示例片段:
“`kotlin
// 假设在一个 Activity 或 ViewModel 的 CoroutineScope 中
// val uiScope = CoroutineScope(Dispatchers.Main + Job())
uiScope.launch {
// 默认在 Main 线程
val result = withContext(Dispatchers.IO) {
// 在 IO 线程执行网络请求或其他耗时操作
println("Performing heavy task on ${Thread.currentThread().name}")
delay(2000) // 模拟网络请求
"Result from IO"
}
// withContext 块执行完毕,自动切回 Main 线程
println("Back to Main thread on ${Thread.currentThread().name}")
// 在这里更新 UI
updateTextView(result) // 这个函数在 Main 线程安全调用
}
“`
第六站:异常处理
协程中的异常处理与传统的 try-catch 块类似,但也需要注意一些协程特有的行为。
- 异常传播: 子协程中的未捕获异常会向上抛给父协程。如果父协程没有处理,异常会一直向上冒泡,直到遇到一个协程作用域的边界或
SupervisorJob
。 launch
vsasync
的异常行为:- 使用
launch
启动的协程,如果发生未捕获异常,该异常会立即抛出,并可能导致父协程及其同级子协程被取消。 - 使用
async
启动的协程,异常会被封装在返回的Deferred
对象中,只有当你调用await()
时才会抛出。这使得可以在需要结果的地方集中处理异常。
- 使用
CoroutineExceptionHandler
: 你可以为协程上下文添加一个CoroutineExceptionHandler
来捕获和处理未被子协程自己捕获的异常。它通常用于顶层协程或特定的作用域。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// 为 launch 协程定义一个异常处理器
val handler = CoroutineExceptionHandler { _, exception ->
println(“Caught exception: $exception”)
}
// launch 协程,带异常处理器
val job = launch(handler) { // 将 handler 添加到协程上下文
throw RuntimeException("Something went wrong in launch!")
}
// async 协程,异常会延迟到 await() 时抛出
val deferred = async {
throw IllegalArgumentException("Something went wrong in async!")
// return@async "This won't be reached"
}
// 等待 launch 协程完成 (或因异常终止)
job.join()
// 尝试获取 async 的结果,会抛出异常
try {
deferred.await()
} catch (e: Exception) {
println("Caught exception from async: $e")
}
println("Program continues after handling exceptions.")
}
“`
输出:
Caught exception: java.lang.RuntimeException: Something went wrong in launch!
Caught exception from async: java.lang.IllegalArgumentException: Something went wrong in async!
Program continues after handling exceptions.
** SupervisorJob:**
在某些场景下(例如 Android UI 开发),你可能不希望一个子协程的失败导致其所有同级子协程以及父协程被取消。这时可以使用 SupervisorJob
。SupervisorJob
不会将子协程的取消或异常传播给其父协程或同级协程,但子协程的异常仍然需要被自身处理(例如在子协程内部使用 try-catch)或通过子协程上下文的 CoroutineExceptionHandler
来捕获。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// 使用 SupervisorJob 作为父 Job
val supervisor = SupervisorJob()
withContext(CoroutineExceptionHandler { _, _ -> } + supervisor) { // 为作用域添加 SupervisorJob 和一个空的异常处理器(避免未捕获异常)
launch { // 子协程 1
delay(100)
println(“Child 1 is finished”)
}
launch { // 子协程 2,会抛出异常
delay(50)
throw RuntimeException(“Child 2 failed!”)
}.let {
// 子协程的异常需要被捕获,否则即使有 SupervisorJob,异常也会冒泡到 CoroutineExceptionHandler 或导致应用崩溃
// 推荐在子协程内部处理异常,或者依赖作用域的 CoroutineExceptionHandler
}
launch { // 子协程 3
delay(200)
println(“Child 3 is finished”)
}
}
delay(1000) // 等待一段时间观察输出
println(“Main is done”)
}
“`
注意: 在上面的 SupervisorJob
例子中,直接在 launch
块内部抛出的异常如果没有被捕获,仍然需要一个 CoroutineExceptionHandler
来处理,否则默认的异常处理器会把它当作未捕获异常。SupervisorJob
只改变了传播方向,不代表异常会被忽略。更安全的做法是在可能抛异常的子协程内部使用 try-catch
。
第七站:更进一步:其他常用模式和概念 (简述)
withTimeout
和withTimeoutOrNull
: 用于设置协程的超时时间。如果在指定时间内未完成,会抛出TimeoutCancellationException
或返回null
。awaitAll
: 当你有多个async
任务,并且需要等待它们全部完成后获取结果列表时使用。- Channels (通道): 用于协程之间安全地发送和接收数据流。类似于阻塞队列,但适用于协程。
- Flow (流): 用于处理异步数据流。是 Kotlin 协程对响应式流(Reactive Streams)的实现,可以处理随时间产生的一系列值。Flow 是协程中处理多个异步值的主要方式。
第八站:协程 vs 线程 vs 回调 vs RxJava
-
协程 vs 线程:
- 协程更轻量:创建和切换开销远小于线程。
- 协程是协作式多任务:同一线程上的协程主动让出执行权(通过挂起函数),而不是被操作系统抢占。
- 协程提供结构化并发:更容易管理生命周期、取消和错误处理。
- 协程不替代线程:协程最终还是要运行在线程上。
-
协程 vs 回调:
- 协程的代码更像同步代码,避免了回调嵌套,提高了可读性。
- 协程的异常处理更集中,可以使用 try-catch。
-
协程 vs RxJava (或其他响应式库):
- 两者都能很好地处理异步和并发。
- 协程是 Kotlin 语言的一部分,更容易集成和使用,概念相对较少。
- Flow (协程的流) 和 RxJava 的 Observable 在处理异步数据流方面功能相似。
- 在很多简单的异步场景下,协程比 RxJava 更简洁。但在复杂的流处理和操作符方面,RxJava 可能更强大和成熟。选择哪个取决于项目需求、团队熟悉度以及生态系统的支持。目前 Kotlin 协程在 Android 和 Kotlin 多平台开发中越来越流行。
结语
恭喜你迈出了学习 Kotlin Coroutines 的第一步!协程是一个强大的工具,它能显著简化异步和并发编程的复杂性,让你用更清晰、更安全的方式编写代码。
本文带你了解了协程的核心概念:挂起函数、作用域、上下文、构建器、Job、调度器和结构化并发,并通过基本示例展示了它们的用法。我们还初步探讨了取消和异常处理。
这仅仅是协程世界的冰山一角。要真正掌握协程,还需要更多的实践和学习,例如:
- 深入了解
CoroutineScope
的创建和管理,尤其是在 Android 等特定平台中。 - 学习协程的取消和异常处理的更高级用法(如
SupervisorJob
和异常传播规则)。 - 学习如何使用 Channels 进行协程间的通信。
- 学习如何使用 Flow 处理异步数据流。
- 学习如何将协程与现有的异步库集成(例如使用 Retrofit 的协程适配器)。
勇敢地在你的项目中尝试使用协程吧!从简单的后台任务开始,逐步将复杂的异步逻辑重构为协程。你会发现,一旦掌握了它,异步编程将变得前所未有的简单和愉快。
继续学习,不断实践,祝你在 Kotlin 协程的旅程中顺利前行!