知识图库
Java知识库
JDK线程池实现原理
Java中的强、软、弱、虚引用
深入拆解Java虚拟机
01 开篇词 | 为什么我们要学习Java虚拟机?
02 Java代码是怎么运行的?
03 Java的基本类型
04 Java虚拟机是如何加载Java类的?
05 JVM是如何执行方法调用的?(上)
06 JVM是如何执行方法调用的?(下)
7 JVM是如何处理异常的?
Java面试常见问题整理
Java面试常见问题-Java 基础篇
Java面试常见问题-Jvm篇
Java面试常见问题-并发篇
Android知识库
Kotlin编程第一课
1 开篇词 | 入门Kotlin有多容易,精通Kotlin就有多难
2 Kotlin基础语法:正式开启学习之旅
3 面向对象:理解Kotlin设计者的良苦用心
4 Kotlin原理:编译器在幕后干了哪些“好事”?
5 实战:构建一个Kotlin版本的四则运算计算器
6 object关键字:你到底有多少种用法?
7 扩展:你的能力边界到底在哪里?
8 高阶函数:为什么说函数是Kotlin的“一等公民”?
9 实战:用Kotlin写一个英语词频统计程序
10 加餐一 | 初识Kotlin函数式编程
11 委托:你为何总是被低估?
12 泛型:逆变or协变,傻傻分不清?
13 注解与反射:进阶必备技能
14 实战:用Kotlin实现一个网络请求框架KtHttp
15 加餐二 | 什么是“表达式思维”?
16 加餐三 | 什么是“不变性思维”?
17 加餐四 | 什么是“空安全思维”?
18 春节刷题计划(一)| 当Kotlin遇上LeetCode
19 春节刷题计划(二)| 一题三解,搞定版本号判断
20 春节刷题计划(三)| 一题双解,搞定求解方程
21 春节刷题计划(四)| 一题三解,搞定分式加减法
22 什么是“协程思维模型”?
23 如何启动协程?
24 挂起函数:Kotlin协程的核心
25 Job:协程也有生命周期吗?
26 Context:万物皆为Context?
27 实战:让KtHttp支持挂起函数
28 期中考试 | 用Kotlin实现图片处理程序
29 题目解答 | 期中考试版本参考实现
30 Channel:为什么说Channel是“热”的?
31 Flow:为什么说Flow是“冷”的?
32 select:到底是在选择什么?
33 并发:协程不需要处理同步吗?
34 异常:try-catch居然会不起作用?坑!
35 实战:让KtHttp支持Flow
36 答疑(一)| Java和Kotlin到底谁好谁坏?
37 集合操作符:你也会“看完就忘”吗?
38 协程源码的地图:如何读源码才不会迷失?
39 图解挂起函数:原来你就是个状态机?
40 加餐五 | 深入理解协程基础元素
41 launch的背后到底发生了什么?
42 Dispatchers是如何工作的?
43 CoroutineScope是如何管理协程的?
44 图解Channel:如何理解它的CSP通信模型?
45 图解Flow:原来你是只纸老虎?
46 Java Android开发者还会有未来吗?
47 Kotlin与Jetpack简直是天生一对!
48 用Kotlin写一个GitHub Trending App
49 结课测试 | “Kotlin编程第一课”100分试卷等你来挑战!
50 结束语 | 不忘初心
Android Framework 教程—基础篇
01 Ubuntu 使用快速入门
02 Make 构建工具入门
03 理解 Unicode UTF-8 UTF-16 UTF-32
04 Linux Shell 脚本编程入门1——核心基础语法
05 SeAndroid 使用极速上手
06 理解 C++ 的 Memory Order
07 AOSP 极速上手
08 系统开发工具推荐
09 添加 Product
运动相关知识
爱上跑步
01 开篇词 | 跑步,不那么简单的事儿
02 跑两步就喘了,是不是我不适合跑步?
03 正确的跑步姿势是什么样的?
04 为什么跑步要先热身?
05 怎样制定你的第一个10公里跑步计划?
06 快跑和慢跑,哪个更燃脂?
07 普通跑步者应该如何选择跑鞋?
08 买跑步装备,不要踩这些坑儿
09 跑步前到底应不应该吃东西?
10 跑步到底伤不伤膝盖?
11 参加了20场马拉松,我是如何准备的?
12 除了马拉松,还能参加哪些跑步赛事?
13 热点问题答疑 :跑完第二天浑身疼,还要不要继续跑?
健身房计划
[DeepSeek]减脂塑形计划
【DeepSeek】训练周期安排
每日餐饮热量控制
减脂期间食物推荐避坑指南
HarmonyOS知识库
其他知识类目
心理学相关
如何学点心理学——关于非专业人士学心理学的一点建议
投射性认同
-
+
首页
39 图解挂起函数:原来你就是个状态机?
今天我们来研究Kotlin挂起函数的实现原理。 挂起函数,是整个Kotlin协程的核心,它的重要性不言而喻。几乎所有协程里的知识点,都离不开挂起函数。而且也正是因为挂起函数的原因,我们才可以使用协程简化异步任务。 今天这节课,我会从这个CPS转换开始说起,带你进一步挖掘它背后的细节。在这个过程中,我们还会接触到Kotlin库当中的协程基础元素:Continuation、CoroutineContext与挂起函数的底层联系。最后,我会带你灵活运用下这些知识点,以此进一步完善我们的KtHttp,让它可以直接支持挂起函数。 好,接下来,我们就正式开始吧! ### CPS转换背后的细节 在第15讲当中,我们已经初步介绍过挂起函数的用法了:挂起函数,只是比普通的函数多了suspend关键字。有了这个suspend关键字以后,Kotlin编译器就会特殊对待这个函数,将其转换成一个带有Callback的函数,这里的Callback就是Continuation接口。 而这个过程,我们称之为CPS转换:  以上的CPS 转换过程中,函数的类型发生了变化:suspend ()->String 变成了 (Continuation)-> Any?。这意味着,如果你在Java里访问一个Kotlin挂起函数getUserInfo(),会看到 getUserInfo()的类型是 (Continuation)-> Object,也就是:接收 Continuation 为参数,返回值是Object。 而在这里,函数签名的变化可以分为两个部分:函数参数的变化和函数返回值的变化。 ### CPS参数变化 我们先来看函数参数的变化,suspend()变成 (Continuation)的情况,这里我们以第15讲当中的代码为例: ```kotlin // 代码段1 suspend fun testCoroutine() { val user = getUserInfo() val friendList = getFriendList(user) val feedList = getFeedList(user, friendList) log(feedList) } //挂起函数 // ↓ suspend fun getUserInfo(): String { withContext(Dispatchers.IO) { delay(1000L) } return "BoyCoder" } //挂起函数 // ↓ suspend fun getFriendList(user: String): String { withContext(Dispatchers.IO) { delay(1000L) } return "Tom, Jack" } //挂起函数 // ↓ suspend fun getFeedList(user: String, list: String): String { withContext(Dispatchers.IO) { delay(1000L) } return "{FeedList..}" } ``` 上面这段代码,testCoroutine()是一个挂起函数,它的内部依次调用了三个挂起函数。而如果我们从Java的角度来看待testCoroutine()的话,代码中所有的参数都会发生变化。如下所示: ```kotlin // 代码段2 // 变化在这里 // ↓ fun testCoroutine(continuation: Continuation): Any? { // 变化在这里 // ↓ val user = getUserInfo(continuation) // 变化在这里 // ↓ val friendList = getFriendList(user, continuation) // 变化在这里 // ↓ val feedList = getFeedList(friendList, continuation) log(feedList) } ``` 可见,在这里的testCoroutine()当中,每一次函数调用的时候,continuation都会作为最后一个参数传到挂起函数里。不过这一步是Kotlin编译器帮我们做的,我们开发者是无感知的。还记得第15讲我留下的思考题吗:为什么挂起函数可以调用挂起函数,普通函数则不能? 其实,这个问题的答案,我们从代码段2就可以看出来。请想象一下,如果testCoroutine()只是一个普通函数,那它就不会有continuation这个参数了,这样getUserInfo()、getFriendList()、getFeedList()这几个挂起函数自然也就无法被调用了。 ### CPS返回值变化 好,接下来我们看看getUserInfo()的返回值类型的变化: ```kotlin // 代码段3 suspend fun getUserInfo(): String {} // 变化在这里 // ↓ fun getUserInfo(cont: Continuation): Any? {} ``` 从上面的代码里,可以看到getUserInfo()的返回值类型从String变成“Any?”。你肯定会好奇,函数原本的String返回值难道丢失了吗?如果原本的返回值类型丢失了,那么程序执行难道不会出问题吗? 其实并不是这样。Kotlin官方之所以要弄这一套CPS转换规则,它必然是“等价转换”。也就是说,String这个原本的返回值类型肯定不会消失,而是会换一种形式存在。只是String存在的形式,经过Kotlin反编译成Java之后会丢失。如果你直接在Java当中调用getUserInfo()的话,就会发现String这个返回值类型成为了Continuation的泛型类型。  所以,对于getUserInfo()这个方法,经过CPS转换后,它完整的函数签名应该是这样的: ```kotlin // 代码段4 suspend fun getUserInfo(): String {} // 变化在这里 // ↓ fun getUserInfo(cont: Continuation): Any? {} ``` 这时候,我们就可以更新第15讲当中的那个CPS动图了:  好,现在我们知道了,挂起函数原本的返回值类型String只是挪了个地方,所以,Kotlin编译器的CPS转换仍然是等价的转换。也就是:suspend () -> String 转换成 (Continuation) -> Any?。不过,这里的“Any?”又是干什么的呢? 其实,挂起函数经过 CPS 转换后,它的返回值有一个重要作用:标志该挂起函数有没有被挂起。这听起来有点绕:挂起函数,就是可以被挂起的函数,它还能不被挂起吗? 是的,挂起函数也能不被挂起。 让我们来理清几个概念。只要有suspend修饰的函数,它就是挂起函数,比如我们前面的例子: ```kotlin // 代码段5 suspend fun getUserInfo(): String { withContext(Dispatchers.IO) { delay(1000L) } return "BoyCoder" } ``` 当getUserInfo()执行到withContext{} 的时候,就会返回 CoroutineSingletons.COROUTINE_SUSPENDED 表示函数被挂起了。 现在问题来了,请问下面这个函数是挂起函数吗? ```kotlin // 代码段6 // suspend 修饰 // ↓ suspend fun noSuspendFriendList(user: String): String{ // 函数体跟普通函数一样 return "Tom, Jack" } ``` 这个其实是 noSuspendFriendList()方法,它的方法体跟普通函数一样。它跟一般的挂起函数有个区别:在执行的时候,它并不会被挂起,因为它就是个普通函数。当你写出以下这样的代码后,IDE也会提示你,suspend是多余的:  也就是,当我们调用noSuspendFriendList()这个挂起函数的时候,它不会真正挂起,而是会直接返回String类型:"no suspend"。针对这样的挂起函数,你可以把它看作是伪挂起函数。 所以到这里,挂起函数经过CPS转换后,返回值变成“Any?”的原因也就清晰了: 由于suspend修饰的函数,既可能返回 CoroutineSingletons.COROUTINE_SUSPENDED,也可能返回实际结果 "no suspend",甚至可能返回 null,为了适配所有的可能性,CPS 转换后的函数返回值类型就只能是 Any? 了。 可见我在第15讲当中给出的这个CPS动图,仅仅只是粗略模拟了协程的CPS流程,其中还有很多细节没有体现出来。  那么,为了让你对挂起函数的底层实现原理有一个更加清晰的认识,接下来,我们来看看挂起函数反编译之后会变成什么样。 ### 挂起函数的反编译 我们知道,通过查看Kotlin反编译后的字节码,可以帮助我们理解Kotlin的底层原理。不过,和往常不一样的是,这次我不会直接贴反编译后的代码,因为它的逻辑比较复杂。 所以,为了方便你理解,接下来我贴出的代码是我用Kotlin翻译后大致等价的代码,改善了可读性,抹掉了不必要的细节。当你理解其中的思想后,再去看反编译后的Java代码,会更轻松一些。 好,我们进入正题,这是我们即将研究的对象,testCoroutine()反编译前的代码: ```kotlin // 代码段7 suspend fun testCoroutine() { log("start") val user = getUserInfo() log(user) val friendList = getFriendList(user) log(friendList) val feedList = getFeedList(friendList) log(feedList) } ``` 接下来我们来分析testCoroutine()的函数体,它相当复杂,涉及到三个挂起函数的调用。 首先,在 testCoroutine() 函数里,会多出一个 ContinuationImpl 的子类,它是整个协程挂起函数的核心。 ```kotlin // 代码段8 fun testCoroutine(completion: Continuation): Any? { // TestContinuation本质上是匿名内部类 class TestContinuation(completion: Continuation?) : ContinuationImpl(completion) { // 表示协程状态机当前的状态 var label: Int = 0 // 协程返回结果 var result: Any? = null // 用于保存之前协程的计算结果 var mUser: Any? = null var mFriendList: Any? = null // invokeSuspend 是协程的关键 // 它最终会调用 testCoroutine(this) 开启协程状态机 // 状态机相关代码就是后面的 when 语句 // 协程的本质,可以说就是 CPS + 状态机 override fun invokeSuspend(_result: Result): Any? { result = _result label = label or Int.Companion.MIN_VALUE return testCoroutine(this) } } } ``` 代码中的这个TestContinuation类,是Kotlin编译器帮我们创建的匿名内部类,这里为了方便才用的TestContinuation这个名称。在这个类当中定义了几个成员变量: - label是用来代表协程状态机当中状态的; - result是用来存储当前挂起函数执行结果的; - mUser、mFriendList则是用来存储历史挂起函数执行结果的; - invokeSuspend这个函数,是整个状态机的入口,它会将执行流程转交给testCoroutine()进行再次调用。 接下来是要判断 testCoroutine 是不是初次运行,如果是初次运行,我们就要创建一个 TestContinuation 的实例对象。 ```kotlin // 代码段9 // ↓ fun testCoroutine(completion: Continuation): Any? { ... val continuation = if (completion is TestContinuation) { completion } else { // 作为参数 // ↓ TestContinuation(completion) } } ``` 也就是: - invokeSuspend 最终会调用 testCoroutine,然后走到这个判断语句; - 如果是初次运行,会创建一个 TestContinuation 对象,completion 作为参数; - 这相当于用一个新的 Continuation 包装了旧的 Continuation; - 如果不是初次运行,直接将 completion 赋值给 continuation; - 这说明 continuation 在整个运行期间,只会产生一个实例,这能极大地节省内存开销(对比CallBack)。 接下来是几个变量的定义: ```kotlin // 代码段10 // 三个变量,对应原函数的三个变量 lateinit var user: String lateinit var friendList: String lateinit var feedList: String // result 接收协程的运行结果 var result = continuation.result // suspendReturn 接收挂起函数的返回值 var suspendReturn: Any? = null // CoroutineSingletons 是个枚举类 // COROUTINE_SUSPENDED 代表当前函数被挂起了 val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED ``` 上面的代码,分别代表了函数当中的临时变量、挂起函数执行结果,以及是否挂起的标志位。接着,我们来看看协程状态机的核心逻辑: ```kotlin // 代码段11 when (continuation.label) { 0 -> { // 检测异常 throwOnFailure(result) log("start") // 将 label 置为 1,准备进入下一次状态 continuation.label = 1 // 执行 getUserInfo suspendReturn = getUserInfo(continuation) // 判断是否挂起 if (suspendReturn == sFlag) { return suspendReturn } else { result = suspendReturn //go to next state } } 1 -> { throwOnFailure(result) // 获取 user 值 user = result as String log(user) // 将协程结果存到 continuation 里 continuation.mUser = user // 准备进入下一个状态 continuation.label = 2 // 执行 getFriendList suspendReturn = getFriendList(user, continuation) // 判断是否挂起 if (suspendReturn == sFlag) { return suspendReturn } else { result = suspendReturn //go to next state } } 2 -> { throwOnFailure(result) user = continuation.mUser as String // 获取 friendList 的值 friendList = result as String log(friendList) // 将协程结果存到 continuation 里 continuation.mUser = user continuation.mFriendList = friendList // 准备进入下一个状态 continuation.label = 3 // 执行 getFeedList suspendReturn = getFeedList(user, friendList, continuation) // 判断是否挂起 if (suspendReturn == sFlag) { return suspendReturn } else { result = suspendReturn //go to next state } } 3 -> { throwOnFailure(result) user = continuation.mUser as String friendList = continuation.mFriendList as String feedList = continuation.result as String log(feedList) loop = false } } ``` 在testCoroutine()这个方法体当中,一共调用了三个挂起函数,这三个挂起函数把整个方法体分割成了4个部分,这四个部分就是上面when表达式当中的4种情况。 - when 表达式实现了协程状态机; - continuation.label 是状态流转的关键,continuation.label 改变一次,就代表了挂起函数被调用了一次; - 每次挂起函数执行完后,都会检查是否发生异常; - testCoroutine 里的原本的代码,被拆分到状态机里各个状态中,分开执行; - getUserInfo(continuation)、getFriendList(user, continuation)、getFeedList(friendList, continuation) 三个函数调用的是同一个 continuation实例; - 如果一个函数被挂起了,它的返回值会是 CoroutineSingletons.COROUTINE_SUSPENDED; - 在挂起函数执行的过程中,状态机会把之前的结果以成员变量的方式保存在 continuation中。 上面这一大串文字和代码看着是不是有点晕?你可以再结合着来看看这个视频演示。  那到这里是不是就结束了呢?并不,因为这个动画仅演示了每个协程正常挂起的情况。如果协程并没有真正挂起呢?协程状态机会怎么运行? ### 协程未挂起的情况 要验证也很简单,我们将其中一个挂起函数改成伪挂起函数即可。 ```kotlin // 代码段12 // “伪”挂起函数 // 虽然它有 suspend 修饰,但执行的时候并不会真正挂起,因为它函数体里没有其他挂起函数 // ↓ suspend fun noSuspendFriendList(user: String): String{ return "Tom, Jack" } suspend fun testNoSuspend() { log("start") val user = getUserInfo() log(user) // 变化在这里 // ↓ val friendList = noSuspendFriendList(user) log(friendList) val feedList = getFeedList(friendList) log(feedList) } ``` testNoSuspend()这样的一个函数体,它反编译后的代码逻辑是怎么样的? 答案其实很简单,它的结构跟前面的testCoroutine()是一致的,只是函数名字变了而已,Kotlin编译器CPS转换的逻辑只认suspend关键字。就算挂起函数内部并没有挂起的逻辑,Kotlin编译器也照样会进行CPS转换。 ```kotlin // 代码段13 when (continuation.label) { 0 -> { ... } 1 -> { ... // 变化在这里 // ↓ suspendReturn = noSuspendFriendList(user, continuation) // 判断是否挂起 if (suspendReturn == sFlag) { return suspendReturn } else { result = suspendReturn //go to next state } } 2 -> { ... } 3 -> { ... } } ``` 那testNoSuspend()的协程状态机是怎么运行的呢? 其实我们也很容易能想到,continuation.label = 0, 2, 3的情况都是不变的,唯独在 label = 1 的时候,suspendReturn == sFlag 这里会有区别。 具体区别我们还是通过动画来看吧:  通过动画我们很清楚地看到了,对于“伪挂起函数”,suspendReturn == sFlag 是会走 else 分支的,在 else 分支里,协程状态机会直接进入下一个状态。 现在只剩最后一个问题了: ```kotlin // 代码段14 if (suspendReturn == sFlag) { } else { // 具体代码是如何实现的? // ↓ //go to next state } ``` 答案其实也很简单:如果你去看协程状态机的字节码反编译后的 Java,会看到很多 label。协程状态机底层字节码,是通过 label来实现这个 go to next state 的。由于 Kotlin 没有类似 goto 的语法,下面我用伪代码来表示 go to next state 的逻辑。 ```kotlin // 代码段15 // 伪代码 // Kotlin 没有这样的语法 // ↓ ↓ label: whenStart when (continuation.label) { 0 -> { ... } 1 -> { ... suspendReturn = noSuspendFriendList(user, continuation) if (suspendReturn == sFlag) { return suspendReturn } else { result = suspendReturn // 让程序跳转到 label 标记的地方 // 从而再执行一次 when 表达式 goto: whenStart } } 2 -> { ... } 3 -> { ... } } ``` 需要注意的是:以上只是伪代码,它只是跟协程状态机字节码逻辑上“大致等价”。真实的字节码反编译出来的Java代码,它的可读性要差很多,也更难理解。 ```kotlin // 代码段16 // 看不懂也没关系,有个印象即可 @Nullable public static final Object testCoroutine(@NotNull Continuation $completion) { Object $continuation; label37: { if ($completion instanceof ) { $continuation = ()$completion; if (((()$continuation).label & Integer.MIN_VALUE) != 0) { (()$continuation).label -= Integer.MIN_VALUE; break label37; } } $continuation = new ContinuationImpl($completion) { // $FF: synthetic field Object result; int label; Object L$0; Object L$1; @Nullable public final Object invokeSuspend(@NotNull Object $result) { this.result = $result; this.label |= Integer.MIN_VALUE; return TestSuspendKt.testCoroutine(this); } }; } Object var10000; label31: { String user; String friendList; Object var6; label30: { Object $result = (()$continuation).result; var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED(); switch((()$continuation).label) { case 0: ResultKt.throwOnFailure($result); log("start"); (()$continuation).label = 1; var10000 = getUserInfo((Continuation)$continuation); if (var10000 == var6) { return var6; } break; case 1: ResultKt.throwOnFailure($result); var10000 = $result; break; case 2: user = (String)(()$continuation).L$0; ResultKt.throwOnFailure($result); var10000 = $result; break label30; case 3: friendList = (String)(()$continuation).L$1; user = (String)(()$continuation).L$0; ResultKt.throwOnFailure($result); var10000 = $result; break label31; default: throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine"); } user = (String)var10000; log(user); (()$continuation).L$0 = user; (()$continuation).label = 2; var10000 = getFriendList(user, (Continuation)$continuation); if (var10000 == var6) { return var6; } } friendList = (String)var10000; log(friendList); (()$continuation).L$0 = user; (()$continuation).L$1 = friendList; (()$continuation).label = 3; var10000 = getFeedList(friendList, (Continuation)$continuation); if (var10000 == var6) { return var6; } } String feedList = (String)var10000; log(feedList); return Unit.INSTANCE; } ``` 当然,对于上面反编译出来的Java代码,即使你看不懂也没关系,你只需要理解我们前面讲解的逻辑即可。本质上来说,Kotlin协程就是通过 label 代码段嵌套,配合 switch 巧妙构造出一个状态机结构,这种逻辑比较复杂,相对难懂一些。毕竟 Java 的 label 在实际开发中用的很少。 >d 注意:Kotlin挂起函数反编译出来的Java代码,会因为实际开发环境的不同出现细微差异。随着Kotlin编译器的发展,将来可能会对这部分逻辑进一步优化,但它的核心状态机思想是不会轻易改变的。 好,到现在,我们就已经彻底弄懂挂起函数的实现原理了。接下来,我们就结合刚刚学习的内容,来进一步思考实战一下。 ### 思考与实战 在上节课我曾提到过,Kotlin协程的源代码其实分为三层,其中基础层当中的“基础概念”尤为重要。那么,Kotlin官方为我们提供了哪些与挂起函数相关的基础元素呢? 我们首先想到的,肯定就是Continuation.kt,在这里面,确实也可以找到一些跟挂起函数相关的基础元素。 ```kotlin // 代码段17 public interface Continuation { public val context: CoroutineContext public fun resumeWith(result: Result) } @Suppress("WRONG_MODIFIER_TARGET") public suspend inline val coroutineContext: CoroutineContext get() { throw NotImplementedError("Implemented as intrinsic") } ``` 在上面的代码中,我们最熟悉的就是Continuation这个接口了,除此之外,还有一个顶层的变量值得我们注意:suspend inline val coroutineContext。要知道,我们从来都是用suspend修饰函数的,从未见过suspend修饰变量的情况。 如果我们依葫芦画瓢,创建一个类似的顶层变量的话,编译器甚至会报错: ```kotlin // 代码段18 // 报错 public suspend inline val test: CoroutineContext get() = TODO() ``` 由此可见,suspend的这种用法只是一种特殊用法。结合“public suspend inline val”这几个关键字来看,我们其实可以大致推测出它的作用:它是一个只有在挂起函数作用域下,才能访问的顶层的不可变的变量。这里的inline,意味着它的具体实现会被直接复制到代码的调用处。 ### 17讲思考题解答 为了验证我们前面的猜测,我们可以回过头来看看第17讲的思考题: ```kotlin // 代码段19 import kotlinx.coroutines.* import kotlin.coroutines.coroutineContext // 挂起函数能可以访问协程上下文吗? // ↓ suspend fun testContext() = coroutineContext 如果你将上面的代码反编译成Java的话,它就会变成这样: // 代码段20 public static final Object testContext(Continuation $completion) { return $completion.getContext(); } ``` 由此可见,代码段17当中的“suspend inline val coroutineContext”,本质上就是Kotlin官方提供的一种方便开发者在挂起函数当中,获取协程上下文的手段。它的具体实现,其实是Kotlin编译器来完成的。 ```kotlin // 代码段19 import kotlinx.coroutines.* import kotlin.coroutines.coroutineContext // Continuation当中的coroutineContext // ↓ suspend fun testContext() = coroutineContext ``` 到这里,你就会发现一个有趣的现象:我们在挂起函数当中无法直接访问Continuation对象,但可以访问到Continuation当中的coroutineContext。要知道,正常情况下,我们想要访问Continuation.coroutineContext,首先是要拿到Continuation对象的。 但是,Kotlin官方通过“suspend inline val coroutineContext”这个顶层变量,让我们开发者能直接拿到coroutineContext,却对Continuation毫无感知。 所以到这里,我们其实也就可以回答第17节课思考题的问题了。 课程里,我提到了“挂起函数”与 CoroutineContext 也有着紧密的联系,请问,你能找到具体的证据吗? >i **解答:**挂起函数与 CoroutineContext 确实有着紧密的联系。每个挂起函数当中都会有Continuation,而每个Continuation当中都会有coroutineContext。并且,我们在挂起函数当中,就可以直接访问当前的coroutineContext。 ### KtHttp支持挂起函数 在第18讲当中,我们并没有让KtHttp直接支持挂起函数,当时我们的做法是给KtCall扩展了一个await()方法,从而实现挂起函数调用的。 那么,经过这节课的学习,我们就可以来尝试让KtHttp直接支持挂起函数了,也就是我们可以这样来写代码: ```kotlin interface ApiServiceV7 { @GET("/repo") // 1,挂起函数 suspend fun reposSuspend( @Field("lang") lang: String, @Field("since") since: String ): RepoList } private fun invoke(path: String, method: Method, args: Array): Any? { // 省略 return when { isSuspend(method) -> { // 2,支持挂起函数 } isKtCallReturn(method) -> { // 省略 } isFlowReturn(method) -> { // 省略 } else -> { // 省略 } } } // 3,判断是不是挂起函数 private fun isSuspend(method: Method) = method.kotlinFunction?.isSuspend ?: false // 4,真正执行网络请求的方法 suspend fun realCall(call: Call, gson: Gson, type: Type): T = suspendCancellableCoroutine { continuation -> call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { continuation.resumeWithException(e) } override fun onResponse(call: Call, response: okhttp3.Response) { try { val t = gson.fromJson(response.body?.string(), type) continuation.resume(t) } catch (e: Exception) { continuation.resumeWithException(e) } } }) continuation.invokeOnCancellation { call.cancel() } } ``` 这段代码一共有4个注释,我们一个个看: - 注释1,这其实就是我们希望达到的效果,可以在ApiServiceV接口当中直接定义挂起函数。 - 注释2,在KtHttp 6.0版本的基础上,我们在invoke()的when表达式里增加了一个分支:isSuspend()。 - 注释3,isSuspend()的实现有一个细节,这里我们使用了“method.kotlinFunction”,将Java的method转换成了kotlinFunction,这样一来,它就变成了一个Kotlin反射的对象了。因此,我们就可以查询到一些Kotlin相关的信息,比如说,它是不是一个挂起函数。 - 注释4,为了直接在挂起函数里执行网络请求,我们将KtCall当中的部分代码逻辑挪了进来。这个realCall()方法,它被定义成了一个挂起函数。 基于以上的分析,我们其实只需要借助反射,完成注释2处的代码逻辑,然后调用realCall()这个挂起函数就行了。 ```kotlin private fun invoke(path: String, method: Method, args: Array): Any? { // 省略 return when { isSuspend(method) -> { // 1,反射获取类型信息 // 2,调用realCall() } isKtCallReturn(method) -> { // 省略 } isFlowReturn(method) -> { // 省略 } else -> { // 省略 } } } ``` 所以,接下来我们要做的事情大致可以分为两个部分。 - 第一个部分,获取类型信息,准备请求网络,这个部分其实很简单。但在第二个部分“支持挂起函数”这里会遇到问题: ```kotlin private fun invoke(path: String, method: Method, args: Array): Any? { // 省略 return when { isSuspend(method) -> { // 支持挂起函数 val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException() val call = okHttpClient!!.newCall(request) // 报错!! realCall() } isKtCallReturn(method) -> { // 省略 } isFlowReturn(method) -> { // 省略 } else -> { // 省略 } } } ``` 以上代码报错的原因也很容易理解,realCall()是一个挂起函数,它无法在普通函数里被调用!所以这里我们就面临了一个问题:如何在普通Kotlin函数当中调用挂起函数? 那么,我们首先可以想到的解决办法,就是强制类型转换: ```kotlin private fun invoke(path: String, method: Method, args: Array): Any? { // 省略 return when { isSuspend(method) -> { // 支持挂起函数 val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException() val call = okHttpClient!!.newCall(request) val continuation = args.last() as? Continuation // 1,将挂起函数类型转换成,带Continuation的类型,报错 val func = ::realCall as (Call, Gson, Type, Continuation?) -> Any? func.invoke(call, gson, genericReturnType, continuation) } isKtCallReturn(method) -> { // 省略 } isFlowReturn(method) -> { // 省略 } else -> { // 省略 } } } ``` 请留意代码中的注释1,我们尝试使用“函数引用”的方式,将realCall()转换成了带有Continuation的函数类型,这样我们就可以通过传入Continuation,来调用realCall()这个挂起函数了。 不过,事与愿违,我们的方法并不能奏效,因为这行代码会报错,原因是realCall()带有泛型,而Kotlin暂时不支持“函数引用带泛型”的语法。 所以在这里,为了让这个Demo能运行起来,我们可以定义一个临时方法: ```kotlin private fun invoke(path: String, method: Method, args: Array): Any? { // 省略 return when { isSuspend(method) -> { // 支持挂起函数 val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException() val call = okHttpClient!!.newCall(request) val continuation = args.last() as? Continuation // 1,使用临时方法消除泛型 val func = ::temp as (Call, Gson, Type, Continuation?) -> Any? func.invoke(call, gson, genericReturnType, continuation) } isKtCallReturn(method) -> { // 省略 } isFlowReturn(method) -> { // 省略 } else -> { // 省略 } } } suspend fun temp(call: Call, gson: Gson, type: Type) = realCall(call, gson, type) ``` 在上面的代码中,我们使用了一个临时方法消除了泛型T,写死了返回值类型RepoList。这样的代码,在Demo当中是可以运行的,这从侧面也能印证我们上面代码中的类型转换是成功的。 ```kotlin fun main() = runBlocking { val data: RepoList = KtHttpV7.create(ApiServiceV7::class.java).reposSuspend( lang = "Kotlin", since = "weekly" ) println(data) } /* 输出结果 正常 */ ``` 不过,这种做法明显不具备普适性,为了让KtHttp支持所有类型的API请求,我们必须要想其他的办法。具体来说,我们可以这样做: ```kotlin private fun invoke(path: String, method: Method, args: Array): Any? { // 省略 return when { isSuspend(method) -> { // 支持挂起函数 val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException() val call = okHttpClient!!.newCall(request) val continuation = args.last() as? Continuation val func = KtHttpV7::class.getGenericFunction("realCall") // 反射调用realCall() func.call(this, call, gson, genericReturnType, continuation) } isKtCallReturn(method) -> { // 省略 } isFlowReturn(method) -> { // 省略 } else -> { // 省略 } } } // 2,获取方法的反射对象 fun KClass<*>.getGenericFunction(name: String): KFunction<*> { return members.single { it.name == name } as KFunction<*> } ``` 其实,这种思路跟前面的思路是类似的,我们仍然是对realCall()的类型进行了转换,只不过是通过反射来实现的而已。所以最重要的,我们还是要弄清楚Kotlin挂起函数CPS转换的细节。 ### 小结 这节课,我们通过研究挂起函数的反编译代码,发现了Kotlin的挂起函数,本质上就是一个状态机。其中主要涉及到下面几个知识点,我们需要重点掌握好。 Kotlin挂起函数的CPS转换,它的函数签名变化主要分为两个部分,第一部分是参数的变化,挂起函数经过Kotlin编译器转换以后,它会多出一个Continuation类型的参数。第二部分是返回值类型的变化,挂起函数原本的返回值类型,会被挪到Continuation当中作为泛型参数,比如 Continuation,而转换过后的函数返回值类型会变成“Any?”类型。 当挂起函数经过反编译以后,它会变成由switch和label组成的状态机结构。 为了便于研究,课程里提供了大致等价的协程状态机代码:其中,when 表达式实现了协程状态机,而continuation.label 则代表了当前状态机的具体状态,continuation.label 改变一次,就代表了挂起函数被调用了一次; 在一个挂起函数被调用的时候,它的返回值可能是具体的结果,也可能会是 CoroutineSingletons.COROUTINE_SUSPENDED,这时候就代表了这个函数被挂起了。 另外在这节课里,我们还进行了一次反思和实战,通过研究协程基础层当中的“suspend inline val coroutineContext”这个顶层变量,我们发现了挂起函数与协程上下文之间的紧密联系。并且,我们还灵活运用了这节课学到的知识,进一步改进了KtHttp,让它可以直接支持挂起函数。 你在自己的工作场景当中,其实也可以通过这样思考与实战的方式,来进一步强化所学和所得,甚至可以把输入转化成输出,把知识真正沉淀成你自己的东西。 ### 思考题 我们都知道挂起函数是Kotlin协程里才有的概念,请问,Java代码中可以调用Kotlin的挂起函数吗?比如,下面这个函数,我们可以在Java当中调用吗? ```kotlin object SuspendFromJavaExample { // 在Java当中如何调用这个方法? suspend fun getUserInfo(id: Long):String { delay(1000L) return "Kotlin" } } ```
嘿手大叔
2024年10月30日 19:01
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
Markdown文件
分享
链接
类型
密码
更新密码