一、什么是协程?
协程是Kotlin提供的一种轻量级的线程管理框架,它允许我们以同步的方式编写异步代码,让代码更加简洁易读。与线程相比,协程的创建和切换开销更小,一个应用程序可以轻松创建数千个协程而不会导致性能问题。
二、为什么在Android中使用协程?
- 避免回调地狱:以顺序的方式编写异步代码
- 主线程安全:轻松切换线程,确保UI操作在主线程执行
- 简化错误处理:使用try-catch处理异步操作中的异常
- 生命周期感知:与Android组件生命周期自动绑定
三、协程的使用
1、常用Api
先列举下关于协程的常用的六种的Api以及对用的各种功能,如下表:
函数 | 作用 | 启动协程 |
launch { } 串行 | 启动无返回的协程、异常会立即抛出给父级,导致整个作用域取消。 | ✅ |
async { } 并行 | 启动有返回的协程(并行)异常只在调用 |
✅ |
withContext(Dispatchers.x) { } | 一次性切换线程并返回结果 | ❌ |
runBlocking { } | 阻塞当前线程直到协程完成(不常用) | ❌ |
coroutineScope { } | 子作用域,失败时全部取消 | ❌ |
supervisorScope { } | 子作用域,失败时不影响兄弟协程 | ❌ |
这6种api各自有各自的功能:
- launch、async用于启动新协程的构建器 (真正意义上的“开启协程”)
- withContext、coroutineScope、supervisorScope用于控制线程和作用的域构建器 (在已有协程内划分作用域)
- runBlocking一个特殊的阻塞式构建器 (主要用于测试,非Android日常开发)
2、使用示例
(1)、launch(无返回)
// 在 Activity / ViewModel 中
lifecycleScope.launch {
Log.d("TAG", "launch 开始")
delay(1000)
Log.d("TAG", "launch 结束") // 1 秒后打印
}
(2)、async(有返回,并行)
suspend fun main() = coroutineScope {
val a = async { delay(800); 1 }
val b = async { delay(600); 2 }
val sum = a.await() + b.await() // 两个 delay 并行跑
println(sum) // 输出 3
}
suspend fun main() = coroutineScope {
val a = async { delay(800); 1 }
val b = async { delay(600); 2 }
// 一行等全部,返回 List<Int>
val list = awaitAll(a, b) // 并行等待,顺序与入参一致
println(list.sum()) // 输出 3
}
async开启协程的方式写了两个示例,因为这里 awaitAll() 和 await() 的使用上有点区别
方式 | 代码 | 异常传播 | 适用场景 |
---|---|---|---|
逐个 await() |
a.await()+b.await() |
第一个异常会阻断第二个 | 数量少 |
awaitAll() |
awaitAll(a, b) |
合并异常,一次性抛出 | 数量多,更整洁 |
3.1在开启协程时可以指定线程的作用域
launch和 async函数本身可以接受一个 CoroutineContext类型的参数(通过参数指定上下文),你可以通过这个参数来指定协程的调度器、异常处理器等。从广义上讲,这也是在“设置”协程运行的上下文环境。
// 在 ViewModel 的 viewModelScope 这个“父作用域”中启动新协程
viewModelScope.launch { // 这个 launch 是 viewModelScope 的子协程
// 代码
}
viewModelScope.async { // 这个 async 也是 viewModelScope 的子协程
// 代码
}
//指定线程
// 设置调度器:在IO线程池运行
viewModelScope.launch(Dispatchers.IO) {
// 网络请求等IO操作
}
// 设置异常处理器
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
viewModelScope.launch(exceptionHandler) {
// 可能会抛出异常的代码
}
// 可以组合多个上下文元素
viewModelScope.launch(Dispatchers.Default + exceptionHandler) {
// ...
}
(3)、withcontext(一次性切线程并拿结果)
withContext 可以切到 任何 Dispatcher,常用只有这3个:
Dispatcher类型 | 线程 | 使用场景 |
Dispatchers.Main | 主线程(UI) | 更新界面 |
Dispatchers.IO | 子线程池 | 网络 / 文件 / 数据库 |
Dispatchers.Default | 子线程池 | CPU 密集计算 |
Dispatchers.IO和 Dispatchers.Default管理的都是子线程(后台线程),但它们是为完全不同类型的工作任务而设计的,因此它们的底层线程池策略有显著区别。
suspend fun main() {
val threadName = withContext(Dispatchers.IO) {
Thread.currentThread().name // 在 IO 线程池里执行
}
println(threadName) // 例如:DefaultDispatcher-worker-1
}
(4)、runBlocking(阻塞主线程,仅测试用)
fun main() = runBlocking {
println("start")
delay(1000)
println("end") // 整个 main 会等 1 秒
}
(5)、coroutineScope(子作用域,任一失败全部取消)
suspend fun main() = coroutineScope {
launch {
delay(200)
throw RuntimeException("boom") // 异常
}
launch {
delay(1000)
println("never reach") // 被取消
}
}
(6)、supervisorScope(子作用域,失败互不影响)
suspend fun main() = supervisorScope {
launch {
delay(200)
throw RuntimeException("boom") // 兄弟不受影响
}
launch {
delay(500)
println("still alive") // 会打印
}
}
- launch / async / withContext:业务代码用的最多
- runBlocking:单元测试时使用
- coroutineScope / supervisorScope:并发任务时控制异常
四、挂起函数
1、什么是挂起函数
说到协程,就不得不说提到挂起函数,什么是挂起函数?
标记:suspend 关键字。
能力:只能在协程或另一个挂起函数里调用。
本质:是一种可以被协程挂起(暂停执行),而不会阻塞其所在线程的函数。编译器把函数切成「状态机」,遇到 delay()、withContext() 等挂起点就挂起,线程空出来干别的,等结果回来再恢复继续执行。
2、挂起函数是如何工作的?
挂起函数的背后是 状态机 和 Continuation 概念。
编译器魔法:当你编译一个 suspend
函数时,编译器会做额外的转换。它会为协程体生成一个状态机(State Machine)。每个挂起点(即调用另一个 suspend
函数的地方)都成为状态机的一个可能状态。
Continuation:可以把它理解为一个回调对象,它封装了 “协程在挂起之后该如何恢复执行” 的信息,包括它应该从哪一行代码继续、当时的局部变量是什么等等。
当你调用 withContext(Dispatchers.IO) { ... }时,实际上发生了:
- 协程在执行到 withContext时,会挂起。
- 它将 withContext块内的代码和 Continuation(恢复信息)一起提交给协程调度器。
- 调度器安排一个线程(IO线程池中的线程)来执行这个块。
- 执行完毕后,调度器再通知协程:“你交代的任务完成了”,并把结果和 Continuation一起,安排回原来的线程(或者你指定的调度器,如 Dispatchers.Main)。
- 协程根据 Continuation的信息,恢复到挂起点之后的状态,继续执行。
上面这种描述太抽象,我们不如想象成一个快递员(一个线程)在送包裹(执行任务)。
1、普通函数(阻塞):快递员到了一个办公室楼下,需要等收件人下来签字。在收件人下来之前,他什么都不做,就干等着(阻塞)。这期间他没法去送别的包裹,效率很低。
2、回调函数(非阻塞但复杂):快递员把包裹交给前台,并留下一个纸条(回调函数)说:“等收件人来了,打电话叫我回来签字”。然后他就去送别的包裹了。等前台打电话来,他再回来处理。这样效率高了,但流程变得复杂,如果包裹多,需要留很多纸条,管理起来很混乱(回调地狱)。
3、挂起函数(挂起-恢复):快递员到了办公室楼下,他给收件人打了个电话说:“我到了,你下来吧”。在收件人下楼的这段时间里,他并没有干等着,而是被派去送隔壁楼的另一个小包裹(挂起当前任务,线程去干别的事了)。等收件人快到楼下了,快递员也送完隔壁的小包裹回来了,然后顺利签字完成主要任务。
在这个比喻中:
- 快递员:就是一个线程。
- 送主要包裹:就是执行协程体里的代码。
- 打电话让收件人下楼:就是调用一个
suspend
函数(比如 delay, withContext, 或者你的自定义挂起函数)。 - 去送隔壁的小包裹:线程被释放,可以去执行其他任务(可能是其他协程的任务)。
- 收件人下楼完成,快递员回来签字:挂起的条件满足,协程在(可能是原来的,也可能是另一个)线程上恢复,继续执行后面的代码。
关键点:
1、挂起函数不会阻塞线程,而是释放线程去干别的活,等它等待的操作(如网络请求、磁盘IO、延迟)完成后,协程会在合适的时机和线程上恢复执行。
2、挂起函数本身不指定线程:suspend关键字只是一个标记,告诉编译器这个函数可以在协程中使用并可能挂起。它本身并不包含任何线程信息。线程由调度器(Dispatcher)决定:真正决定代码在哪个线程上运行的是协程的上下文中的 CoroutineDispatcher(协程调度器)➡️(Dispatchers.Main / IO / Default)。
3、挂起函数的作用域不一定在子线程中。它的执行线程完全取决于它在被调用时所在的协程上下文(CoroutineContext),以及它内部使用的调度器(Dispatcher)。挂起函数的核心是“挂起”(suspend),而不是“切换线程”。线程切换只是实现挂起的一种常用手段。
3、挂起函数的使用场景
(1)情况一:在主线程启动,并在主线程调用挂起函数
viewModelScope.launch(Dispatchers.Main) { // 1. 在主线程启动协程
// 2. 当前上下文是 Dispatchers.Main
doSomeWork() // 3. 调用挂起函数
}
// 这个挂起函数没有使用 withContext 切换线程
// 因此它将继承调用者的上下文,即在主线程运行
suspend fun doSomeWork() {
// 这里的代码会在 Dispatchers.Main 上执行
// 如果在这里执行耗时操作,会阻塞主线程!
heavyOperation() // ❌ 危险!会阻塞UI!
}
fun heavyOperation() {
Thread.sleep(2000) // 模拟耗时阻塞操作
}
这是最常见的Android场景。协程在主线程启动,挂起函数内部没有切换上下文,那么它就会在主线程运行。在这个例子中,挂起函数 doSomeWork()的作用域是主线程。
(2)情况二:正确的“主线程安全”挂起函数
viewModelScope.launch(Dispatchers.Main) { // 1. 在主线程启动协程
// 2. 当前上下文是 Dispatchers.Main
val result = doSomeSafeWork() // 3. 调用挂起函数(挂起点)
updateUI(result) // 6. 恢复后,仍在主线程,安全更新UI
}
// 这是一个主线程安全的挂起函数
suspend fun doSomeSafeWork(): String {
// 4. 函数开始执行时,仍在主线程
// 但 withContext 会将协程的执行挂起,并将代码块交给 IO 调度器
return withContext(Dispatchers.IO) {
// 5. 这个代码块现在在IO线程池中的某个线程执行
// 模拟网络请求或数据库操作,不会阻塞主线程
Thread.sleep(2000)
"Result from network"
}
// withContext 完成后,协程会自动切回原来的上下文(Dispatchers.Main)
// 所以返回值是在主线程被接收的
}
一个良好的挂起函数应该内部处理线程切换,保证无论从哪个线程调用它,其耗时操作都在后台进行,并最终将结果返回给调用方线程。
doSomeSafeWork() 函数内部的 withContext 代码块的作用域是子线程(IO线程)。但从外部看,这个函数被调用和返回的上下文(viewModelScope通常是 Dispatchers.Main)是主线程。
(3)情况三:在子线程启动协程
viewModelScope.launch(Dispatchers.Default) { // 1. 在Default线程池启动
// 2. 当前上下文是 Dispatchers.Default
doSomeWork() // 3. 这个挂起函数将在 Default 线程执行
}
suspend fun doSomeWork() {
// 在 Dispatchers.Default 上执行
}
如果明确指定一个后台调度器启动协程,那么挂起函数(如果不内部切换)就会在那个后台线程运行。
(4)总结
场景 |
挂起函数所在线程 |
说明 |
---|---|---|
默认情况 |
继承调用方协程的上下文 |
挂起函数不自动切换线程,它在哪个线程被调用,就在哪个线程运行。 |
使用 |
由 |
这是主动控制挂起函数内部代码执行线程的标准方式。 |
设计目标 |
实现主线程安全 |
一个好的挂起函数应该内部使用 |
挂起函数的作用域不一定在子线程中。它的线程环境是动态的和可预测的。
动态的:取决于调用它的协程上下文和它内部使用的调度器。
可预测的:开发者可以通过 withContext精确地控制其内部代码应该在哪个线程上执行。
注意:
1、永远不要假设一个挂起函数会在后台线程运行。如果要执行耗时操作,必须在挂起函数内部使用 withContext(Dispatchers.IO)或 withContext(Dispatchers.Default)来明确切换到合适的线程。这才是编写“主线程安全”挂起函数的关键。
2、结构化并发(Structured Concurrency),这是协程设计的核心哲学,要求协程的生命周期与它的启动作用域(如 ViewModel的 viewModelScope或 Activity的 lifecycleScope)绑定。