挂起函数,是 Kotlin 协程当中最基础、最重要的知识点。如果对协程的挂起函数没有足够的认识,我们将无法理解协程的非阻塞;如果不了解挂起函数,我们将无法掌握 Channel、Flow 等 API;如果不理解挂起函数,我们写出来的代码也会漏洞百出,就更别提优化软件架构了。
一、挂起函数:Kotlin 协程的优势
比如现在有一个需求,需要先通过接口获取用户信息后,再通过用户信息调接口获取好友列表,再通过好友列表调接口获取聊天记录,在java中只能通过嵌套接口回调实现,而现在有了协程的挂起和恢复就变得很简单,代码如下:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
System.setProperty("kotlinx.coroutines.debug", "on") //开启debug打印协程名称
lifecycleScope.launch { //协程
logMsg("lifecycleScope.launch start")
val user = getUserInfo() //获取用户信息
val list = getFriendList(user) //获取好友列表
getChatRecord(list) //获取聊天记录
logMsg("lifecycleScope.launch end")
}
}
//获取用户信息
suspend fun getUserInfo(): String {
val result = withContext(Dispatchers.IO) { //开启协程,withContext是挂起函数
logMsg("getUserInfo withContext") //打印协程的线程
delay(2000L) //模拟网络请求耗时
return@withContext "getUserInfo" //协程返回值
}
logMsg("getUserInfo result:$result") //打印返回值和线程
return result
}
//获取好友列表
suspend fun getFriendList(user: String): String {
val result = withContext(Dispatchers.IO) { //开启协程,withContext是挂起函数
logMsg("getFriendList withContext") //打印协程的线程
delay(2000L) //模拟网络请求耗时
return@withContext "getFriendList" //协程返回值
}
logMsg("getFriendList result:$result") //打印返回值和线程
return result
}
//获取聊天记录
suspend fun getChatRecord(list: String): String {
val result = withContext(Dispatchers.IO) { //开启协程,withContext是挂起函数
logMsg("getChatRecord withContext") //打印协程的线程
delay(2000L) //模拟网络请求耗时
return@withContext "getFriendInfo" //协程返回值
}
logMsg("getChatRecord result:$result") //打印返回值和线程
return result
}
//打印日志方法
fun logMsg(msg: String) {
Log.d("TAG", "${Thread.currentThread().name}:$msg")
}
}
输出的结果
2023-02-16 14:08:07.291 D/TAG: main @coroutine#2:lifecycleScope.launch start //主线程
2023-02-16 14:08:07.297 D/TAG: DefaultDispatcher-worker-1 @coroutine#2:getUserInfo withContext //子线程:协程挂起
2023-02-16 14:08:09.300 D/TAG: main @coroutine#2:getUserInfo result:getUserInfo //主线程:协程恢复
2023-02-16 14:08:09.301 D/TAG: DefaultDispatcher-worker-1 @coroutine#2:getFriendList withContext //子线程:协程挂起
2023-02-16 14:08:11.302 D/TAG: main @coroutine#2:getFriendList result:getFriendList //主线程:协程恢复
2023-02-16 14:08:11.303 D/TAG: DefaultDispatcher-worker-3 @coroutine#2:getChatRecord withContext //子线程:协程挂起
2023-02-16 14:08:13.304 D/TAG: main @coroutine#2:getChatRecord result:getFriendInfo //主线程:协程恢复
2023-02-16 14:08:13.304 D/TAG: main @coroutine#2:lifecycleScope.launch end //主线程
用一张图来理解就是
所谓的挂起函数,其实就是比普通的函数多了一个 suspend 关键字而已。如果去掉这个 suspend 关键字,所有的函数都会变成普通函数。实际上,挂起函数最神奇的地方,就在于它的挂起和恢复功能。从字面上看,suspend 这个词就是“挂起”的意思,而它既然能被挂起,自然就还可以被恢复。它们两个一般是成对出现的。
在上面代码中:
- 每一次从主线程到 IO 线程,都是一次协程挂起。
- 每一次从 IO 线程到主线程,都是一次协程恢复。
- 挂起和恢复,这是挂起(
suspend)函数特有的能力,普通函数是不具备的。 - 挂起,只是将程序执行流程转移到了其他线程,主线程不会被阻塞。如果以上代码运行在 Android 系统,我们的 App 仍然可以响应用户的操作,主线程并不繁忙。
借助挂起函数,我们可以用同步的方式来写异步代码,对比起“回调地狱”式的代码,挂起函数写出来的代码可读性更好、扩展性更好、维护性更好,并且更难出错。
二、深入理解 suspend
加了suspend的函数(挂起函数)和普通函数有什么区别呢?
首先,suspend函数只能在协程中或其他suspend函数中被调用,其次看下面的代码以及反编译的结果:
//代码
suspend fun foo():String{
return "Hello World"
}
fun foo1():String{
return "Hello World"
}
//反编译后
@Nullable
public final Object foo(@NotNull Continuation $completion) { //多了一个参数Continuation
return "Hello World";
}
@NotNull
public final String foo1() {
return "Hello World";
}
相比于普通函数,挂起函数会多了一个传参,我们看看Continuation是什么?
/**
* Interface representing a continuation after a suspension point that returns a value of type `T`.
* 表示一个中止点暂停后恢复运行,返回泛型T类型数据的接口
*/
@SinceKotlin("1.3")
public interface Continuation<in T> {
/**
* The context of the coroutine that corresponds to this continuation.
* 用于恢复运行的协程上下文
*/
public val context: CoroutineContext //持有协程上下文
/**
* Resumes the execution of the corresponding coroutine passing a successful or failed [result] as the
* return value of the last suspension point.
* 协程通过返回结果恢复运行
*/
public fun resumeWith(result: Result<T>)
}
Continuation相当于协程回调接口,返回协程运行的结果。单独给函数添加suspend并没有挂起和恢复的含义,只是部分suspend函数实现了协程的挂起和恢复,比如withContext函数,源码如下:
/**
* Calls the specified suspending block with a given coroutine context, suspends until it completes, and returns
* the result.
* 通过给的协程上下文调用规定的挂起来阻塞,并最终返回执行的结果
* ......
*/
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T {
......
}
具体源码分析暂略。
三、协程与挂起函数
既然协程和挂起函数都是支持挂起和恢复的,那它们两个是不是同一个东西呢?答案当然是否定的。
现阶段我们会发现协程和suspend函数都可以调用suspend函数,那可以得到如下结论:挂起和恢复,是协程的一种底层能力;而挂起函数,是这种底层能力的一种表现形式,通过暴露出来的 suspend 关键字,我们开发者可以在上层,非常方便地使用这种底层能力。
四、挂起与线程的关系
1、挂起时要等待挂起的协程执行完,会不会影响阻塞当前线程?
不会,挂起的协程会在新的线程执行,不会阻塞当前线程。
2、嵌套的协程作用域,内部协程被挂起是否会阻塞外部协程?
会,嵌套的协程作用域,如果内部协程被挂起,外部协程会被阻塞,请看下面的代码:
GlobalScope.launch {
log("GlobalScope start")
val job = launch {
log("job start")
delay(4000)
log("job end")
}
job.join()
log("GlobalScope mid")
job.invokeOnCompletion {
log("job Completion")
}
log("GlobalScope end")
}
//输出日志
16:09:29.064 DefaultDispatcher-worker-1 @coroutine#1 GlobalScope start
16:09:29.064 DefaultDispatcher-worker-2 @coroutine#2 job start //被挂起
16:09:33.070 DefaultDispatcher-worker-2 @coroutine#2 job end //直到4秒后结束
16:09:33.072 DefaultDispatcher-worker-2 @coroutine#1 GlobalScope mid //继续输出外部协程作用域的日志
16:09:33.073 DefaultDispatcher-worker-2 @coroutine#1 job Completion
16:09:33.073 DefaultDispatcher-worker-2 @coroutine#1 GlobalScope end