从零开始学 Kotlin 协程:快速上手指南
协程是 Kotlin 中一个强大且令人兴奋的特性,它极大地简化了异步编程,让你可以用同步的方式编写非阻塞的代码。这听起来有点矛盾,但正是协程的魅力所在。如果你之前被回调地狱、复杂的线程管理所困扰,那么协程绝对值得你花时间学习。
本文将带你从零开始,一步步了解 Kotlin 协程的核心概念、基本用法,以及一些常见的应用场景。让我们开始这段协程之旅吧!
1. 为什么需要协程?
在深入协程的细节之前,我们先来思考一个问题:为什么我们需要协程?或者说,传统的异步编程方式有什么问题?
传统的异步编程方式主要有以下几种:
- 回调(Callbacks): 这是最基本的方式,通过传递回调函数来处理异步操作的结果。但是,当异步操作嵌套层数较多时,就会形成“回调地狱”,代码可读性和可维护性极差。
- Future/Promise: 这种方式将异步操作的结果封装成一个 Future 或 Promise 对象,可以通过链式调用来处理结果。相比回调,它稍微好一些,但仍然不够简洁。
- 线程(Threads): 通过创建新的线程来执行耗时操作,避免阻塞主线程。但是,线程的创建和销毁开销较大,而且线程数量过多时,会导致线程上下文切换频繁,降低系统性能。
这些方式都存在一些问题,要么是代码难以阅读和维护,要么是资源消耗较大。而协程的出现,正是为了解决这些问题。
协程的优势:
- 简化异步编程: 协程让你以同步的方式编写异步代码,避免了回调地狱和复杂的线程管理。
- 轻量级: 协程非常轻量级,创建和切换的开销远小于线程。你可以在一个线程中创建数百万个协程,而不会导致系统资源耗尽。
- 非阻塞: 协程的挂起和恢复是非阻塞的,不会阻塞线程。当一个协程挂起时,线程可以去执行其他任务,从而提高系统的吞吐量。
- 结构化并发: 协程提供了结构化的并发机制,可以更好地控制协程的生命周期,避免资源泄漏和协程失控。
2. 协程的核心概念
要理解协程,你需要掌握以下几个核心概念:
- 协程(Coroutine): 协程本质上是一种轻量级的线程。它可以挂起(suspend)和恢复(resume)执行,而不会阻塞线程。你可以将协程理解为一个可以暂停和继续执行的任务。
- 挂起函数(Suspending Function): 挂起函数是协程的核心。它使用
suspend
关键字修饰,表示这个函数可以被挂起。挂起函数只能在协程或其他挂起函数中调用。 - 协程构建器(Coroutine Builder): 协程构建器是用来启动协程的函数。常见的协程构建器有
launch
、async
和runBlocking
。 - 协程作用域(Coroutine Scope): 协程作用域定义了协程的生命周期。它负责管理协程的启动、取消和异常处理。
- 调度器(Dispatcher): 调度器决定了协程在哪个线程上执行。Kotlin 提供了几种内置的调度器,如
Dispatchers.Default
、Dispatchers.IO
和Dispatchers.Main
。 - 上下文(Context): 协程上下文是一个键值对集合,用于存储协程相关的信息,如调度器、作业(Job)和异常处理器。
- Job 和 Deferred:
Job
对象代表一个协程, 可以用于取消协程。async
构建器会返回一个Deferred
对象, 它继承自Job
, 并且有一个await()
方法, 用于等待协程执行完成并获取结果。
3. 第一个协程程序
让我们从一个简单的例子开始,看看如何创建一个协程:
“`kotlin
import kotlinx.coroutines.*
fun main() {
println(“Start”)
// 使用 launch 启动一个协程
GlobalScope.launch {
delay(1000) // 模拟耗时操作,非阻塞
println("Hello from coroutine!")
}
println("End")
Thread.sleep(2000) // 阻塞主线程,等待协程执行完毕
}
“`
代码解释:
GlobalScope.launch { ... }
:使用launch
协程构建器启动一个协程。GlobalScope
表示协程的生命周期与整个应用程序相同。delay(1000)
:这是一个挂起函数,它会暂停协程 1 秒钟,但不会阻塞线程。Thread.sleep(2000)
:这里使用Thread.sleep
阻塞主线程 2 秒钟,是为了让协程有足够的时间执行完毕。否则,主线程会立即结束,协程可能还没来得及执行。
运行结果:
Start
End
Hello from coroutine!
可以看到,"End"
先于 "Hello from coroutine!"
输出,这说明 delay(1000)
并没有阻塞主线程。
注意: 在实际开发中,通常不建议使用 GlobalScope
,因为它创建的协程生命周期过长,容易导致资源泄漏。我们会在后面介绍更合适的协程作用域。
4. 协程构建器详解
Kotlin 提供了几种协程构建器,它们各有特点和适用场景:
-
launch
: 启动一个协程,但不返回任何结果。它返回一个Job
对象,可以用于取消协程。launch
通常用于执行 fire-and-forget 类型的任务。 -
async
: 启动一个协程,并返回一个Deferred
对象。Deferred
是一个带有结果的Job
,你可以通过调用await()
方法来获取协程的执行结果。async
通常用于执行需要返回结果的异步任务。 -
runBlocking
: 阻塞当前线程,直到协程执行完毕。它通常用于连接阻塞代码和挂起代码,例如在main
函数中启动协程,或者在单元测试中测试挂起函数。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking { // 使用 runBlocking 阻塞主线程
println(“Start”)
// 使用 launch 启动一个协程
val job = launch {
delay(1000)
println("Hello from launch!")
}
// 使用 async 启动一个协程
val deferred = async {
delay(2000)
"Hello from async!"
}
println("Waiting for results...")
println("Result from async: ${deferred.await()}") // 等待 async 协程的结果
job.join() // 等待 launch 协程执行完毕
println("End")
}
“`
代码解释:
runBlocking { ... }
:使用runBlocking
阻塞主线程,直到其中的协程执行完毕。launch { ... }
:启动一个不需要返回结果的协程。async { ... }
:启动一个需要返回结果的协程。deferred.await()
:等待async
协程执行完毕,并获取其结果。job.join()
:等待launch
协程执行完毕。
运行结果:
Start
Waiting for results...
Hello from launch!
Hello from async!
Result from async: Hello from async!
End
5. 协程作用域
协程作用域(Coroutine Scope)定义了协程的生命周期。它负责管理协程的启动、取消和异常处理。
为什么需要协程作用域?
- 结构化并发: 协程作用域提供了一种结构化的并发机制,可以更好地控制协程的生命周期,避免资源泄漏和协程失控。
- 自动取消: 当协程作用域被取消时,它会自动取消其所有子协程。
- 异常处理: 协程作用域可以捕获和处理协程中未捕获的异常。
常见的协程作用域:
GlobalScope
: 全局作用域,协程的生命周期与整个应用程序相同。通常不建议使用,容易导致资源泄漏。CoroutineScope(context)
: 创建一个自定义的协程作用域。你需要传入一个协程上下文(Coroutine Context)作为参数。lifecycleScope
(Android): Android 架构组件Lifecycle
提供的协程作用域,协程的生命周期与Lifecycle
组件(如Activity
、Fragment
)绑定。viewModelScope
(Android): Android 架构组件ViewModel
提供的协程作用域,协程的生命周期与ViewModel
绑定。
示例(Android):
“`kotlin
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 使用 lifecycleScope 启动协程
lifecycleScope.launch {
delay(1000)
println("Hello from lifecycleScope!")
}
}
}
“`
代码解释:
lifecycleScope.launch { ... }
:使用lifecycleScope
启动一个协程。当MainActivity
被销毁时,lifecycleScope
会自动取消其所有子协程。
6. 调度器
调度器(Dispatcher)决定了协程在哪个线程上执行。Kotlin 提供了几种内置的调度器:
Dispatchers.Default
: 默认调度器,适用于 CPU 密集型任务,如计算、排序等。它使用一个共享的线程池。Dispatchers.IO
: IO 调度器,适用于 IO 密集型任务,如网络请求、文件读写等。它也使用一个共享的线程池,但线程池的大小可以根据需要动态调整。Dispatchers.Main
: 主线程调度器,适用于更新 UI。在 Android 中,它对应于主线程(UI 线程)。Dispatchers.Unconfined
: 非受限调度器。 它在调用者的线程中启动协程, 但是仅仅会持续到第一个挂起点。 在挂起之后, 它在相应的挂起函数使用的任何线程中恢复协程。 应该避免使用,除非为了达到某些特殊目的.
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.Default) {
println(“Default: ${Thread.currentThread().name}”)
}
launch(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
}
launch(Dispatchers.Main) { // 如果在非主线程中运行,会抛出异常
println("Main: ${Thread.currentThread().name}")
}
}
“`
代码解释:
launch(Dispatchers.Default)
:在默认调度器上启动协程,通常是后台线程。launch(Dispatchers.IO)
:在 IO 调度器上启动协程,通常也是后台线程。launch(Dispatchers.Main)
:在主线程调度器上启动协程。
运行结果(可能因环境而异):
Default: DefaultDispatcher-worker-1
IO: DefaultDispatcher-worker-2
Exception in thread "main" java.lang.IllegalStateException: ...
如果在非主线程中运行,会抛出Dispatchers.Main
相关的异常,这是因为Dispatchers.Main
只能在主线程(例如Android的UI线程)中使用。
7. 挂起函数
挂起函数是协程的核心。它使用 suspend
关键字修饰,表示这个函数可以被挂起。挂起函数只能在协程或其他挂起函数中调用。
挂起函数的特点:
- 非阻塞: 挂起函数在执行到挂起点时,会暂停协程的执行,但不会阻塞线程。线程可以去执行其他任务。
- 可恢复: 当挂起的操作完成后,协程会在挂起点恢复执行。
- 只能在协程中调用: 挂起函数只能在协程或其他挂起函数中调用。
示例:
“`kotlin
import kotlinx.coroutines.*
suspend fun doSomethingUsefulOne(): Int {
delay(1000) // 模拟耗时操作
return 13
}
suspend fun doSomethingUsefulTwo(): Int {
delay(2000) // 模拟耗时操作
return 29
}
fun main() = runBlocking {
val time = measureTimeMillis {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println(“The answer is ${one.await() + two.await()}”)
}
println(“Completed in $time ms”)
}
“`
代码解释:
suspend fun doSomethingUsefulOne()
和suspend fun doSomethingUsefulTwo()
:定义了两个挂起函数,它们分别模拟了两个耗时操作。async { ... }
:使用async
启动两个协程,分别执行doSomethingUsefulOne()
和doSomethingUsefulTwo()
。one.await()
和two.await()
:等待两个协程执行完毕,并获取其结果。measureTimeMillis
: 用于测量代码执行时间
运行结果:
The answer is 42
Completed in 2017 ms
可以看到,两个协程是并发执行的,总耗时大约是 2 秒,而不是 3 秒。
8. 协程的取消
协程是可以被取消的。你可以通过调用 Job
对象的 cancel()
方法来取消协程。
取消的原理:
- 协程的取消是协作式的。也就是说,协程需要主动检查自己的取消状态,才能响应取消请求。
- Kotlin 提供了一些内置的挂起函数,如
delay
、yield
等,它们会在挂起时检查协程的取消状态。如果协程已经被取消,这些函数会抛出CancellationException
。 - 你也可以通过
isActive
属性来检查协程的取消状态。
示例:
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println(“job: I’m sleeping $i …”)
delay(500L)
}
}
delay(1300L) // 延迟一段时间
println(“main: I’m tired of waiting!”)
job.cancel() // 取消协程
job.join() // 等待协程执行完毕
println(“main: Now I can quit.”)
}
“`
代码解释:
job.cancel()
:取消协程。job.join()
:等待协程执行完毕。由于协程已经被取消,join()
会立即返回。delay
函数会检查协程的取消状态。
运行结果:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.
可以看到,协程在执行到第三次循环时被取消了。
处理 CancellationException
:
通常情况下,你不需要显式地处理 CancellationException
。协程的取消机制会自动处理它。但是,如果你需要在协程被取消时执行一些清理操作,可以使用 try ... finally
块。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println(“job: I’m sleeping $i …”)
delay(500L)
}
} finally {
println(“job: I’m running finally”)
}
}
delay(1300L) // 延迟一段时间
println(“main: I’m tired of waiting!”)
job.cancelAndJoin() // 取消并等待协程
println(“main: Now I can quit.”)
}
“`
代码解释:
* cancelAndJoin()
:这是一个方便的函数,它等价于先调用 cancel()
,再调用 join()
。
运行结果:
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.
9. 异常处理
协程中的异常处理与普通函数的异常处理类似,可以使用 try ... catch
块来捕获和处理异常。
未捕获的异常:
- 如果协程中抛出了一个未捕获的异常,它会沿着协程的层次结构向上传播,直到被某个协程作用域捕获或导致应用程序崩溃。
launch
构建器创建的协程,如果发生未捕获异常,默认会打印异常堆栈并终止程序。async
构建器创建的协程,如果发生未捕获异常,异常会被包装在Deferred
对象中,当你调用await()
方法时,会重新抛出该异常。
使用 CoroutineExceptionHandler
:
你可以创建一个 CoroutineExceptionHandler
对象,并将其传递给协程作用域,来处理协程中未捕获的异常。
“`kotlin
import kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println(“Caught $exception”)
}
val job = GlobalScope.launch(handler) {
throw AssertionError("Something went wrong")
}
job.join()
}
“`
代码解释:
CoroutineExceptionHandler { _, exception -> ... }
:创建一个异常处理器,它会打印捕获到的异常。GlobalScope.launch(handler) { ... }
:使用launch
启动一个协程,并将异常处理器传递给它。
运行结果:
Caught java.lang.AssertionError: Something went wrong
10. 总结
本文详细介绍了 Kotlin 协程的基础知识,包括核心概念、基本用法、协程构建器、协程作用域、调度器、挂起函数、协程的取消和异常处理。
掌握这些知识,你已经可以开始使用协程来简化你的异步编程代码了。当然,协程还有更多高级特性,如通道(Channel)、流(Flow)等,等待你去探索。
希望这篇文章能帮助你快速上手 Kotlin 协程!