掌握 Kotlin Coroutine:轻松开始异步编程
在现代软件开发中,尤其是构建响应式、高效的应用时,异步编程变得至关重要。无论是进行网络请求、数据库操作、文件读写,还是处理耗时计算,我们都需要在不阻塞主线程(特别是UI线程)的情况下完成这些任务,以保持应用的流畅和用户的良好体验。
长期以来,开发者们探索了多种处理异步任务的方式,如回调(Callbacks)、Future/Promise、事件总线、以及直接使用线程。然而,这些方法往往伴随着各自的复杂性:回调地狱(Callback Hell)导致代码难以阅读和维护;线程操作涉及复杂的同步机制、资源开销大、难以取消和管理;而某些其他方案则可能引入额外的学习曲线或框架依赖。
Kotlin Coroutines(协程)的出现,为异步编程带来了革命性的改变。它提供了一种更简洁、更安全、更高效的方式来编写异步代码,让开发者能够用接近同步的编程风格来表达异步逻辑。本文将带你深入了解 Kotlin Coroutines,从基础概念入手,逐步掌握其核心用法和高级特性,让你轻松迈入异步编程的新世界。
1. 异步编程的困境:为什么我们需要更好的方案?
想象一个常见的场景:你的应用需要从远程服务器获取数据,然后在屏幕上显示。传统的同步方式是直接调用一个函数:
“`kotlin
fun fetchDataAndDisplay() {
val data = fetchDataFromServer() // 这是一个耗时的网络请求
updateUI(data) // 使用获取到的数据更新UI
}
fun fetchDataFromServer(): String {
// 模拟网络请求,可能需要几秒钟
Thread.sleep(5000)
return “Data from Server”
}
“`
如果在主线程(UI线程)中直接调用 fetchDataAndDisplay()
,当执行到 fetchDataFromServer()
时,主线程会被阻塞长达5秒。这意味着在这段时间内,应用会完全冻结,用户无法进行任何交互,UI也不会刷新,用户体验极差,甚至可能导致应用无响应(ANR: Application Not Responding)错误。
为了解决这个问题,我们需要将耗时操作放到另一个线程中执行。
方案一:使用多线程(Threads)
kotlin
fun fetchDataAndDisplayAsyncThread() {
Thread {
val data = fetchDataFromServer() // 在子线程执行网络请求
// 问题:不能直接在子线程更新UI,需要切换回主线程
// 例如在Android中需要使用 Handler, runOnUiThread 等
// Handler { updateUI(data) }.post(Runnable { /* ... */ })
// Activity.runOnUiThread { updateUI(data) }
}.start()
}
使用线程虽然解决了阻塞问题,但引入了新的复杂性:
- 线程创建和管理的开销: 创建和销毁线程是比较昂费的资源操作。
- 线程通信: 子线程完成任务后需要通知主线程,这通常需要Handler、回调或其他同步机制,增加了代码的复杂性。
- 同步问题: 如果多个线程需要访问共享资源,还需要考虑线程安全和锁的问题。
- 取消操作: 中途取消一个正在运行的线程并不容易,尤其是处理I/O操作时。
- “回调地狱”或层层嵌套: 当异步任务依赖于另一个异步任务的结果时,代码结构容易变得像俄罗斯套娃一样嵌套,难以阅读和维护。
方案二:使用回调(Callbacks)
为了避免显式的线程切换,我们可能会设计基于回调的API:
“`kotlin
fun fetchDataFromServerAsync(callback: (String) -> Unit) {
// 在一个子线程中执行耗时操作
Thread {
val data = fetchDataFromServer() // 耗时操作
// 切换回主线程执行回调
// 例如:runOnUiThread { callback(data) }
}.start()
}
fun fetchDataAndDisplayAsyncCallback() {
fetchDataFromServerAsync { data ->
updateUI(data)
}
}
“`
这种方式简化了调用者的代码,但如果任务链条变长(例如:先获取用户ID,再根据ID获取用户信息,再根据用户信息获取订单列表),代码就会变成这样:
kotlin
fetchUserIdAsync { userId ->
fetchUserInfoAsync(userId) { userInfo ->
fetchOrderListAsync(userInfo.id) { orderList ->
updateUI(userInfo, orderList)
}
}
}
// 这就是臭名昭著的“回调地狱”
代码层层嵌套,难以理解、调试和维护。错误处理(try/catch
)也变得非常棘手。
Kotlin Coroutines 旨在解决上述问题,提供一种既不阻塞线程、代码又像同步执行一样清晰的异步编程模型。
2. Kotlin Coroutines 登场:轻量级异步的魔法
协程(Coroutine)并非 Kotlin 独有,它是一种编程概念,许多语言都有实现(如 Python 的 asyncio, C# 的 async/await)。Kotlin 的协程实现是基于挂起函数(suspend
functions)和结构化并发(Structured Concurrency)的,这使得它在 Kotlin 中表现得尤为出色和易用。
核心理念:挂起与恢复 (Suspension and Resumption)
与线程不同,协程不是操作系统级别的概念,它们是用户级的、轻量级的。一个线程可以同时运行成千上万个协程。协程的核心魔力在于它的“挂起”(suspend
)能力。
当一个协程遇到一个耗时的操作(比如网络请求),它不会阻塞当前的线程,而是挂起自己。这意味着协程会将当前的执行状态(包括局部变量、程序计数器等)保存起来,然后将执行权交还给调度器或调用者。当耗时操作完成后,协程可以从之前挂起的地方恢复执行,而无需重新创建整个调用栈。
关键在于,挂起和恢复是由协程库在用户空间管理的,对操作系统线程是透明的。一个线程可以在一个协程挂起时,去执行另一个协程,从而充分利用线程资源,避免阻塞。
suspend
关键字
在 Kotlin 中,suspend
是协程的核心标记。它只能用于函数声明中:
kotlin
suspend fun fetchDataFromServer(): String {
// 这是一个挂起函数
// 可以在这里调用其他挂起函数,或者执行耗时操作并让协程挂起
delay(5000) // delay() 是一个挂起函数,它会挂起协程,而不是阻塞线程
return "Data from Server"
}
suspend
关键字告诉编译器:这个函数是一个“可以挂起”的函数。suspend
函数只能在另一个suspend
函数中调用,或者在协程构建器(Coroutine Builder,如launch
,async
,runBlocking
)中调用。- 当一个
suspend
函数内部调用另一个suspend
函数时,如果内部函数挂起,外部函数也会随之挂起。 suspend
本身并 不 意味着异步或运行在新线程。它只表示函数在执行过程中可能会暂停(挂起),并在稍后恢复。实际的异步执行和线程切换是由协程库根据你选择的调度器(Dispatcher)来决定的。
使用协程改写之前的例子:
“`kotlin
import kotlinx.coroutines.*
// 标记为挂起函数
suspend fun fetchDataFromServer(): String {
println(“开始从服务器获取数据…”)
delay(5000) // 挂起协程,不会阻塞线程
println(“数据获取完成.”)
return “Data from Server”
}
fun main() = runBlocking { // runBlocking 是一个协程构建器,用于阻塞式地启动一个协程,常用于主函数或测试
println(“开始主函数…”)
val data = fetchDataFromServer() // 直接调用挂起函数,代码看起来像同步一样
println(“接收到数据: $data”)
// 在实际应用中,这里会调用 updateUI(data)
println(“主函数结束.”)
}
“`
运行这段代码,你会看到输出:
开始主函数...
开始从服务器获取数据...
数据获取完成.
接收到数据: Data from Server
主函数结束.
虽然 delay(5000)
模拟了耗时操作,但因为它发生在 runBlocking
协程中,主线程(这里是 main
函数所在的线程)会被 阻塞 直到 runBlocking
内部的协程完成。runBlocking
主要用于连接阻塞世界和非阻塞协程世界,比如在 main
函数或测试中。
在实际应用中,我们通常使用 launch
或 async
在非阻塞的方式启动协程。
3. 协程的基础构建块
理解以下几个核心概念是掌握 Kotlin Coroutines 的关键:
3.1 CoroutineScope (协程作用域)
CoroutineScope
定义了协程的生命周期。在一个 CoroutineScope
中启动的协程,都会与该 Scope 的生命周期绑定。当 Scope 被取消时,所有在其内部启动的协程都会被取消。
这带来了结构化并发(Structured Concurrency)的概念。结构化并发确保了:
- 不会泄露协程: 当一个操作(如用户界面屏幕)结束时,启动的所有协程都会被取消,避免后台任务继续运行消耗资源。
- 错误传播: 子协程中的错误会传播到父协程,父协程可以处理或取消其兄弟协程。
- 生命周期管理: 协程的生命周期与应用组件(如 Activity, ViewModel)的生命周期自然绑定。
避免使用 GlobalScope
:尽管 GlobalScope.launch { ... }
可以启动一个协程,但 GlobalScope
不受结构化并发的约束。它启动的协程类似于守护线程,不会因为任何其他 Scope 的取消而被取消,容易导致协程泄露和资源浪费。除非你有非常明确的理由,否则应尽量避免使用 GlobalScope
。
在 Android 开发中,Kotlin 提供了 LifecycleScope
和 ViewModelScope
,它们分别与 Activity/Fragment 和 ViewModel 的生命周期绑定,极大地简化了协程管理。在通用 Kotlin 代码中,你可以创建自己的 Scope:
“`kotlin
import kotlinx.coroutines.*
// 创建一个自定义的 CoroutineScope
// CoroutineScope(CoroutineContext)
// CoroutineContext 由一个或多个元素组成,至少需要一个 Job
val myScope = CoroutineScope(Job())
fun main() = runBlocking {
println(“在 runBlocking 中”)
val job = myScope.launch { // 在 myScope 中启动一个协程
repeat(1000) { i ->
println(“myScope job $i …”)
delay(500)
}
}
delay(2000) // 等待一段时间
println("取消 myScope")
myScope.cancel() // 取消整个 scope,所有在其内部启动的协程都会被取消
job.join() // 等待 job 结束(因为被取消,会抛出 CancellationException)
println("myScope job 结束.")
// 再次尝试在 myScope 中启动,会立即结束因为 Scope 已经取消
val job2 = myScope.launch {
println("这个协程不会运行.")
}
job2.join() // job2 立即完成 (Cancelled)
println("主函数结束.")
}
“`
运行代码会看到 myScope job
只输出了几次就被取消了。
3.2 Job (任务)
Job
是一个协程的句柄。每一个协程构建器(launch
除外,它返回 Deferred
)都会返回一个 Job
实例。你可以使用 Job
来:
- 管理协程的生命周期:
job.cancel()
取消协程,job.join()
等待协程完成。 - 检查协程的状态:
job.isActive
,job.isCancelled
,job.isCompleted
. - 构建协程的层级结构: 一个 Job 可以是另一个 Job 的父 Job,形成树状结构。父 Job 的取消会传递给子 Job。
3.3 CoroutineDispatcher (协程调度器)
调度器决定了协程在哪个线程或线程池上执行。你可以通过 CoroutineContext
指定协程使用的调度器。Kotlin Coroutines 提供了几种内置的调度器:
Dispatchers.Default
: 用于 CPU 密集型任务,例如排序、计算、解析 JSON 等。它使用一个共享的后台线程池,线程数默认为 CPU 核心数。Dispatchers.IO
: 用于阻塞式 I/O 操作,例如网络请求、文件读写、数据库操作等。它使用一个线程池,线程数会根据需要增长,但有上限。Dispatchers.Main
: 在支持的平台(如 Android、JavaFX、Swing)上,这是主线程(UI线程)的调度器。用于执行更新 UI 的操作。Dispatchers.Unconfined
: 在调用者线程上启动协程,直到第一次挂起。挂起后,它会在负责恢复的线程上恢复执行。这个调度器不限制协程在特定线程或线程池上执行,通常用于一些特定场景或测试,不推荐用于普通应用代码。
使用 withContext
切换调度器:
在一个协程中,你可以在不同的调度器之间切换。最常用的方法是 withContext
。它会切换到指定的调度器执行一个代码块,并在代码块执行完毕后自动切回原来的调度器。
“`kotlin
import kotlinx.coroutines.*
suspend fun fetchDataFromNetwork(): String {
println(“当前线程 (网络请求前): ${Thread.currentThread().name}”)
return withContext(Dispatchers.IO) { // 切换到 IO 调度器执行网络请求
println(“当前线程 (网络请求中): ${Thread.currentThread().name}”)
delay(3000) // 模拟网络请求
“网络数据”
}
}
suspend fun processData(data: String): String {
println(“当前线程 (数据处理前): ${Thread.currentThread().name}”)
return withContext(Dispatchers.Default) { // 切换到 Default 调度器处理数据
println(“当前线程 (数据处理中): ${Thread.currentThread().name}”)
delay(2000) // 模拟数据处理
“处理后的数据: $data”
}
}
fun main() = runBlocking {
println(“当前线程 (主协程): ${Thread.currentThread().name}”)
val networkData = fetchDataFromNetwork() // 在 IO 调度器上执行
val processedData = processData(networkData) // 在 Default 调度器上执行
println(“最终结果: $processedData”)
println(“当前线程 (主协程结束): ${Thread.currentThread().name}”)
}
“`
运行这段代码,你会看到线程名字在不同的函数调用中发生了变化,但代码依然是顺序执行的,非常直观。withContext
是协程中进行线程切换和并发编程的利器。
3.4 CoroutineContext (协程上下文)
协程上下文是一组元素,它们定义了协程的行为。它是一个 Map
的实现,主要包含以下元素:
Job
: 协程的 Job 实例,管理协程的生命周期。CoroutineDispatcher
: 协程的调度器,决定协程在哪里运行。CoroutineName
: 协程的名称,用于调试。CoroutineExceptionHandler
: 处理未捕获异常的处理器。
你可以通过 +
操作符组合上下文元素:
kotlin
val context: CoroutineContext = Dispatchers.Default + CoroutineName("MyWorker") + Job()
val scope = CoroutineScope(context)
当你在一个 Scope 中启动协程时,子协程会继承父协程的上下文,但可以通过在其构建器中提供新的上下文元素来覆盖部分或全部父上下文。
4. 协程构建器:启动你的协程
有几种主要的方式来启动一个协程:
4.1 launch
- 用途: 启动一个协程执行一个任务,不返回结果。类似于“并发地执行这个操作”。
- 返回值:
Job
,可以用来取消或等待协程完成。 - 异常处理: 未捕获的异常会传播并可能导致父协程取消,最终可能导致应用崩溃(如果没有顶层的
CoroutineExceptionHandler
)。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch { // 在 runBlocking 的 CoroutineScope 中启动一个协程
delay(1000)
println(“任务完成!”)
}
println(“启动了任务”)
job.join() // 等待任务完成
println(“主协程结束”)
}
“`
你可以在 launch
后面传递一个 CoroutineContext
来指定调度器或其他上下文元素:launch(Dispatchers.IO) { ... }
。
4.2 async
- 用途: 启动一个协程执行一个任务,并期望返回一个结果。类似于“并发地计算这个值”。
- 返回值:
Deferred<T>
,Deferred
是Job
的一个子类,带有一个await()
函数,调用await()
会挂起当前协程直到Deferred
结果可用,然后返回结果。 - 异常处理: 异常会被封装在
Deferred
对象中,只有在调用await()
时才会抛出。这使得处理并发任务的异常更方便。
async
常用于并发执行多个独立的任务,然后等待所有结果:
“`kotlin
import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis
suspend fun fetchUser(): String {
delay(2000) // 模拟网络请求
return “用户信息”
}
suspend fun fetchOrders(): String {
delay(3000) // 模拟网络请求
return “订单信息”
}
fun main() = runBlocking {
// 顺序执行,总耗时约 5秒
val timeSequential = measureTimeMillis {
val user = fetchUser()
val orders = fetchOrders()
println(“顺序获取:用户: $user, 订单: $orders”)
}
println(“顺序执行耗时: $timeSequential ms”)
println("\n--- 使用 async 并发 ---")
// 并发执行,总耗时约 3秒 (取最长任务耗时)
val timeConcurrent = measureTimeMillis {
val deferredUser = async { fetchUser() } // 启动一个协程异步获取用户
val deferredOrders = async { fetchOrders() } // 启动一个协程异步获取订单
// await() 是挂起函数,等待各自的结果,但前面的 async 已经并行开始了
val user = deferredUser.await()
val orders = deferredOrders.await()
println("并发获取:用户: $user, 订单: $orders")
}
println("并发执行耗时: $timeConcurrent ms")
}
“`
这个例子清晰地展示了 async
如何让你轻松地实现并发操作并等待所有结果。
4.3 runBlocking
- 用途: 启动一个新的协程并阻塞调用它的当前线程,直到协程完成。
- 返回值: 协程体执行的结果。
- 主要用途: 连接阻塞代码和协程代码,常用于
main
函数、单元测试等需要阻塞等待协程完成的场景。不应在生产环境的非阻塞代码(如 Android UI 线程)中使用runBlocking
,它会阻塞线程!
“`kotlin
import kotlinx.coroutines.*
fun main() {
println(“主线程开始: ${Thread.currentThread().name}”)
runBlocking { // 阻塞主线程直到这个协程完成
println(“runBlocking 协程开始: ${Thread.currentThread().name}”)
delay(2000) // 在协程中挂起
println(“runBlocking 协程结束: ${Thread.currentThread().name}”)
}
println(“主线程结束: ${Thread.currentThread().name}”)
}
“`
输出会显示主线程被阻塞了约2秒。
5. 协程的取消操作
异步任务通常需要被取消。例如,用户关闭了一个正在加载数据的界面。由于结构化并发的存在,取消一个父 Scope 或 Job 会自动取消其所有子协程。
如何取消:
调用 Job 或 Scope 的 cancel()
方法。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println(“工作 $i …”)
delay(500) // 这是一个挂起函数,会检查取消状态
}
}
delay(2100) // 等待一段时间
println(“时间到,取消任务!”)
job.cancel() // 请求取消 job
job.join() // 等待 job 完成取消 (会抛出 CancellationException 并被捕获)
println(“任务已取消.”)
}
“`
运行这段代码,你会看到在打印了几次 “工作 x …” 后,程序输出 “时间到,取消任务!”,然后很快输出 “任务已取消.”。这是因为 delay()
是一个可取消的挂起函数。当 job.cancel()
被调用时,delay()
内部会检测到协程被取消,并抛出 CancellationException
。这个异常会向上冒泡,导致协程终止。
合作式取消:
并非所有代码都会自动响应取消。长时间运行的计算循环是常见的陷阱:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
var nextPrintTime = System.currentTimeMillis()
var i = 0
while (i < 5) { // 这是一个计算密集型循环
// 每秒打印两次,检查是否活动
if (System.currentTimeMillis() >= nextPrintTime) {
println(“计算工作 $i …”)
i++
nextPrintTime += 500 // 模拟一点点延迟以便观察输出
}
// 没有挂起函数,协程不会让出CPU或检查取消状态
}
}
delay(1300) // 等待一秒多
println(“时间到,取消任务!”)
job.cancelAndJoin() // 取消并等待完成
println(“任务已取消.”)
}
“`
运行这段代码,你会发现即使调用了 job.cancel()
,循环依然会完成5次打印,然后协程才结束。这是因为 while (i < 5)
循环是 CPU 密集型的,并且没有调用任何挂起函数,它不会主动检查协程的取消状态。
为了让这种计算密集型任务响应取消,你需要主动检查协程的取消状态:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
var nextPrintTime = System.currentTimeMillis()
var i = 0
while (isActive) { // 使用 isActive 检查取消状态
if (System.currentTimeMillis() >= nextPrintTime) {
println(“可取消计算工作 $i …”)
i++
nextPrintTime += 500
}
// 可以通过 yield() 函数主动让出执行权,同时也会检查取消状态
yield()
}
// 或者在合适的地方抛出 CancellationException
// ensureActive() // 检查是否活跃,不活跃则抛出 CancellationException
}
delay(1300)
println(“时间到,取消任务!”)
job.cancelAndJoin()
println(“任务已取消.”)
}
“`
现在,当调用 cancel()
后,isActive
会变为 false
,循环会终止。yield()
也是一个好的习惯,它不仅检查取消,还能让出线程执行权,让其他协程有机会运行。
6. 异常处理
协程中的异常处理是基于其结构化并发模型的。理解异常如何传播至关重要:
-
launch
构建器中的异常: 如果launch
启动的协程抛出未捕获的异常,该异常会传播到其父协程。如果父协程没有特殊处理(如SupervisorJob
),父协程会被取消,并且异常会继续向上冒泡,影响兄弟协程和更高层级的协程,直到遇到一个能处理它的地方(如CoroutineExceptionHandler
或顶层未处理导致应用崩溃)。 -
async
构建器中的异常:async
启动的协程中的异常会被封装在返回的Deferred
对象中。异常只会在调用deferred.await()
时重新抛出。如果你不调用await()
,或者在调用await()
时没有使用try/catch
捕获,异常可能会被忽略,直到Deferred
对应的 Job 因为其他原因(比如父 Job 取消)而取消,此时被抑制的异常可能会被传递给父 Job。
使用 try/catch
:
对于 async
,通常在调用 await()
的地方使用 try/catch
:
“`kotlin
import kotlinx.coroutines.*
suspend fun failedAsyncTask(): String {
delay(1000)
throw RuntimeException(“Async任务失败!”)
}
fun main() = runBlocking {
val deferred = async
failedAsyncTask()
}
try {
val result = deferred.await()
println("Async 任务成功: $result")
} catch (e: Exception) {
println("Async 任务捕获到异常: ${e.message}")
}
}
“`
对于 launch
,try/catch
只能捕获协程体内部的异常,无法捕获协程自身启动或取消过程中的异常,也无法防止异常传播给父协程。
使用 CoroutineExceptionHandler
:
CoroutineExceptionHandler
是一种特殊的上下文元素,可以附加到协程的上下文,用于处理通过 launch
启动的顶层协程中未捕获的异常。它只对那些未被子协程自行处理并冒泡到该顶层协程的异常有效。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println(“捕获到未处理的异常: $exception”)
}
val job = GlobalScope.launch(handler) { // 注意:这里使用了 GlobalScope 演示,但通常会用自定义 Scope + Job
println("启动抛异常任务")
delay(100)
throw RuntimeException("任务内部抛出异常!")
}
job.join()
println("任务结束")
}
“`
这里 GlobalScope
被用作顶层协程,handler
会捕获其内部未处理的异常。
SupervisorJob
:
在结构化并发中,一个子协程的失败通常会导致其父协程以及所有兄弟协程被取消。但在某些 UI 场景下,我们可能希望一个子任务的失败不会影响到其他的兄弟任务(例如,一个列表中每个item的异步加载任务,一个失败不应影响其他)。这时可以使用 SupervisorJob
。
SupervisorJob
的一个子 Job 失败时,不会向下传播取消给其他子 Job,也不会向上取消父 Job。异常只会通过 CoroutineExceptionHandler
传播。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
// SupervisorJob() 的子任务失败不会取消兄弟任务
val supervisor = CoroutineScope(SupervisorJob())
val job1 = supervisor.launch {
delay(100)
println("任务 1 完成")
}
val job2 = supervisor.launch {
delay(200)
throw RuntimeException("任务 2 失败!")
}
val job3 = supervisor.launch {
delay(300)
println("任务 3 完成")
}
joinAll(job1, job2, job3) // 等待所有任务结束
println("所有 supervisor 任务结束.")
}
“`
运行代码,你会看到任务1和任务3都完成了,只有任务2失败了,而父 Job(supervisor
)并没有被取消。注意,CoroutineExceptionHandler
在 SupervisorJob
的上下文中变得尤为重要,用于处理那些未被子协程 try/catch
的异常。
7. Flow:异步数据流
除了处理单个异步操作或一组并发操作外,有时我们需要处理随着时间推移产生的多个值,例如数据库更新、实时传感器数据、网络数据流等。传统的处理方式可能是回调或响应式流库(如 RxJava)。Kotlin Coroutines 提供了 Flow
来解决这个问题。
Flow
是一个冷流(cold stream),类似于序列(Sequence)。只有当有收集者(collector)开始收集时,Flow 才会开始发射值。它利用协程的挂起机制,可以在发射或处理每个值时挂起,而不会阻塞线程。
“`kotlin
import kotlinx.coroutines.
import kotlinx.coroutines.flow.
fun simpleFlow(): Flow
println(“Flow started”)
for (i in 1..3) {
delay(100) // 模拟异步产生值
emit(i) // 发射值
println(“Emitted $i”)
}
}
fun main() = runBlocking {
println(“Collecting flow”)
simpleFlow().collect { value -> // 收集 flow
println(“Collected $value”)
}
println(“Flow collected”)
}
“`
输出:
Collecting flow
Flow started
Emitted 1
Collected 1
Emitted 2
Collected 2
Emitted 3
Collected 3
Flow collected
可以看到,flow
构建器中的代码(包括 delay
)是在 collect
被调用时才执行的,并且执行是在收集者的协程中进行的。
Flow 提供了丰富的操作符(类似于 RxJava/Sequence 的操作符),如 map
, filter
, onEach
, reduce
, fold
等,用于转换和处理数据流。还有 flowOn
操作符可以在不同的调度器上执行 Flow 的发射或中间操作。
Flow 是一个相对独立的、更高级的主题,这里仅作简要介绍。但它与 Coroutines 紧密集成,是处理异步数据流的强大工具。
8. 最佳实践与常见陷阱
- 始终使用结构化并发: 避免使用
GlobalScope
。使用CoroutineScope
并根据生命周期(如ViewModelScope
,LifecycleScope
或自定义 Scope)管理协程。 - 理解
suspend
的含义: 它只标记函数可挂起,不保证异步或在新线程执行。实际执行在哪取决于调度器和上下文。 - 正确选择调度器: I/O 密集型用
Dispatchers.IO
,CPU 密集型用Dispatchers.Default
,更新 UI 用Dispatchers.Main
。 - 使用
withContext
进行线程切换: 这是在协程内部切换执行上下文的最安全和推荐方式。 - 处理取消: 确保你的耗时计算代码是可取消的,通过检查
isActive
或调用yield()
/ensureActive()
。 - 理解异常传播: 区分
launch
和async
的异常处理方式,使用try/catch
或CoroutineExceptionHandler
根据需要处理异常。对于希望子任务失败不影响兄弟任务的情况,考虑SupervisorJob
。 - 避免在
suspend
函数中阻塞线程:suspend
函数应该通过调用其他suspend
函数或使用协程库提供的非阻塞机制来让出执行权,而不是调用像Thread.sleep()
这样的阻塞方法(除非在withContext(Dispatchers.IO)
块内)。 - 不要在 UI 线程的协程中执行耗时操作: 即使使用了协程,如果你在
Dispatchers.Main
的协程中执行计算或 I/O,仍然会阻塞 UI 线程。务必使用withContext
切换到其他调度器执行耗时代码。 - 测试协程: 使用
kotlinx-coroutines-test
库提供的runTest
或runBlockingTest
来编写协程的单元测试。
9. 总结
Kotlin Coroutines 为异步编程带来了前所未有的简洁性和强大功能。通过挂起函数 (suspend
),我们可以用顺序式的代码风格编写复杂的异步逻辑。结构化并发 (CoroutineScope
, Job
) 解决了传统异步模型中常见的资源泄露、取消困难和错误处理混乱等问题。调度器 (CoroutineDispatcher
) 让我们能够灵活地控制代码在哪个线程上执行,而 withContext
提供了便捷的线程切换能力。async
使得并发执行和结果收集变得易如反掌。
虽然协程的概念初看可能需要一些时间来理解,但一旦掌握,你会发现编写异步代码变得更加愉快和高效。它让你的代码更易读、易维护、易测试,并且能够更好地管理资源和处理并发任务。
异步编程是现代开发的必备技能,而 Kotlin Coroutines 是当前最优秀、最符合 Kotlin 语言风格的异步解决方案之一。现在,你已经了解了 Coroutines 的核心概念和基本用法,是时候在实际项目中动手实践了!从简单的网络请求开始,逐步尝试并发、取消、错误处理等场景,你会越来越得心应手。
Kotlin Coroutines,让异步编程不再是令人头疼的难题,而是提升应用性能和代码质量的利器。开始你的协程之旅吧!