launch
与 withContext
的抉择
对于线程切换的场景,我们之前使用的工具是 launch
。
但 launch
的特性是启动一个并发的新协程,例如:
kotlin
CoroutineScope(context = Dispatchers.Main).launch {
println("1. outer launch - Thread: ${Thread.currentThread().name}")
launch(context = Dispatchers.IO) {
// 模拟一个阻塞线程的耗时操作
Thread.sleep(2000)
println("2. inner launch - Thread: ${Thread.currentThread().name}")
}
println("3. outer launch - Thread: ${Thread.currentThread().name}")
}
以上代码的运行结果将会是:
less
1. outer launch - Thread: main
3. outer launch - Thread: main
2. inner launch - Thread: DefaultDispatcher-worker-1
可以看到:先打印的 3,后打印的 2。
launch
会创建一个新的子协程去执行任务,但它并不会挂起当前父协程的执行。也就是说,父协程会立刻继续往下执行,不会等待子协程完成。
我们使用
Thread.sleep()
来模拟阻塞线程的耗时操作,因为它确实会让线程阻塞。而delay()
是非阻塞的挂起函数,只会暂停当前协程,让当前协程脱离所在线程,而不会阻塞线程。
如果我们需要在后台完成任务,然后等待它的结果再继续执行 ,就需要用到另一个线程切换的工具------withContext
。
kotlin
CoroutineScope(context = Dispatchers.Main).launch {
println("1. launch block.. - Thread: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
Thread.sleep(2000) // 模拟耗时操作
println("2. withContext block... - Thread: ${Thread.currentThread().name}")
}
println("3. launch block.. - Thread: ${Thread.currentThread().name}")
}
上述代码的运行结果将会是:
less
1. launch block.. - Thread: main
2. withContext block... - Thread: DefaultDispatcher-worker-1
3. launch block.. - Thread: main
withContext
能实现这种串行流程,是因为它是一个挂起函数。它挂起了当前协程,在指定的调度器(其他线程)中执行代码。等到代码块执行完成后,会自动切回原来的线程,恢复之前被挂起的协程,所以代码才能这样一步步顺序执行。
挂起函数的介绍在前一篇博客中:初识协程: 为什么需要它以及如何启动第一个协程
withContext
不仅能保证串行执行,还能返回结果,让我们告别回调地狱。
比如:
kotlin
CoroutineScope(Dispatchers.Main).launch {
val userData = withContext(Dispatchers.IO) {
Thread.sleep(2000) // 模拟网络请求
"User-ID: 12345, Name: Jack" // 要返回的结果
}
// 更新 UI
println("data is: $userData")
}
获取数据时,并不需要回调,这就是 withContext
的强大之处。
自定义挂起函数
当 withContext
的逻辑变得复杂时,我们可以将其封装为挂起函数。
suspend
关键字的含义
给一个普通函数加上 suspend
关键字,那么这个函数就变为了挂起函数。
那么,我们在什么时候会需要定义一个挂起函数?
其实,我们并不会主动去定义。只有在函数中需要调用挂起函数时,我们才不得不给这个函数加上 suspend
关键字,让它也变为挂起函数,从而获得调用其他挂起函数的能力。
suspend
的代价
把函数定义为挂起函数,也限制了它的使用范围,让它只能在协程或是另一个挂起函数中被调用。所以,当你为一个函数增加了 suspend
关键字,而函数内部却并没有调用任何挂起函数,IDE 会提示我们移除这个多余的关键字。
实践
封装前的代码:
kotlin
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) {
// 从网络获取数据
Thread.sleep(2000)
"number"
}
val processedData = withContext(Dispatchers.Default) {
// 处理数据
data.random() + data + data.random()
}
// 在主线程使用数据
println("used data is $processedData")
}
封装后:
kotlin
// 获取数据
suspend fun fetchData(): String = withContext(Dispatchers.IO) {
// 从网络获取数据
Thread.sleep(2000)
"number"
}
// 处理数据
suspend fun processData(data: String): String = withContext(Dispatchers.Default) {
// 处理数据
data.random() + data + data.random()
}
lifecycleScope.launch {
val rawData = fetchData()
val processedData = processData(rawData)
// 使用数据
println("used data is $processedData")
}
挂起函数的设计
在上述的封装操作中,会有一个问题:withContext
调度器需不需要被封装进来?
其实是需要的,我们应该将耗时操作和它对应的 withContext
调度器一起被封装进挂起函数中。
这是因为一个操作的类型是固定的,换句话说,一个耗时操作是计算密集型还是IO密集型,是内在的属性,调用者改变不了。数据处理就应该放在 Dispatchers.Default
中,网络请求、文件处理等就应该放在 Dispatchers.IO
中,并不随着调用处的不同而改变。
这里并不用担心线程切换的性能损耗:如果当前已经是目标调度器,
withContext
会进行优化,跳过线程切换,直接在当前线程执行代码。
这种封装模式,本质上是一种责任的转移。
它将保证性能问题的责任从函数的使用者转移到了函数的作者身上,这就从根源避免了因在错误的线程调用函数而导致的性能问题。
而在传统的线程模型中,函数的作者只能通过文档注释来提醒使用者:该方法为耗时操作,需要在后台线程执行。
挂起函数的背后
可以先看我的这篇博客:Android 多线程:理解 Handler 与 Looper 机制,以了解 Android 框架的主线程。
为什么挂起函数不会阻塞调用它的主线程?
kotlin
lifecycleScope.launch {
val data = fetchData() // fetchData 是挂起函数
myTextView.text = data
}
其实,上述看似同步的协程代码,在编译时会被 Kotlin 编译器改造:以 suspend
函数的调用点为界,将函数分为多个部分,然后重组成一个状态机。
状态机的执行流程类似于回调结构:
java
// 伪代码
dispatcher.switchToBackground(() -> {
// fetchData() 挂起函数内部的阻塞性工作
String data = fetchDataBlocking();
// 挂起点之后恢复执行的代码
mainThreadHandler.post(() -> {
myTextView.text = data;
});
});
为什么挂起函数不卡主线程,是因为:
-
挂起 Suspend :协程在主线程启动,将耗时操作提交给后台线程池后,当前协程就会暂停并返回。主线程会得到释放,可以继续无限循环,保持界面响应。
-
恢复 Resume:当耗时操作完成后,协程会将挂起点之后的代码作为任务,提交到主线程的任务队列中。之后,主线程会取出这个任务并执行,UI得到更新。
所以,主线程根本没有等待挂起函数的执行。在协程被挂起的瞬间,主线程就离开了,后续需要在主线程更新UI时,只是提交了一个新任务到主线程的执行队列中而已。