27 | 图解挂起函数:原来你就是个状态机?

你好,我是朱涛。今天我们来研究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讲当中的代码为例:

// 代码段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()的话,代码中所有的参数都会发生变化。如下所示:

// 代码段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()的返回值类型的变化:

// 代码段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转换后,它完整的函数签名应该是这样的:

// 代码段4

suspend fun getUserInfo(): String {}

//                                变化在这里
//                                    ↓
fun getUserInfo(cont: Continuation<String>): Any? {}

这时候,我们就可以更新第15讲当中的那个CPS动图了:

图片

好,现在我们知道了,挂起函数原本的返回值类型String只是挪了个地方,所以,Kotlin编译器的CPS转换仍然是等价的转换。也就是:suspend () -> String 转换成 (Continuation<String>) -> Any?。不过,这里的“Any?”又是干什么的呢?

其实,挂起函数经过 CPS 转换后,它的返回值有一个重要作用:标志该挂起函数有没有被挂起。这听起来有点绕:挂起函数,就是可以被挂起的函数,它还能不被挂起吗?

是的,挂起函数也能不被挂起。

让我们来理清几个概念。只要有suspend修饰的函数,它就是挂起函数,比如我们前面的例子:

// 代码段5

suspend fun getUserInfo(): String {
    withContext(Dispatchers.IO) {
        delay(1000L)
    }
    return "BoyCoder"
}

当getUserInfo()执行到withContext{} 的时候,就会返回 CoroutineSingletons.COROUTINE_SUSPENDED 表示函数被挂起了。

现在问题来了,请问下面这个函数是挂起函数吗?

// 代码段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()反编译前的代码:

// 代码段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 的子类,它是整个协程挂起函数的核心。

// 代码段8

fun testCoroutine(completion: Continuation<Any?>): Any? {
    // TestContinuation本质上是匿名内部类
    class TestContinuation(completion: Continuation<Any?>?) : 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?>): Any? {
            result = _result
            label = label or Int.Companion.MIN_VALUE
            return testCoroutine(this)
        }
    }
}

代码中的这个TestContinuation类,是Kotlin编译器帮我们创建的匿名内部类,这里为了方便才用的TestContinuation这个名称。在这个类当中定义了几个成员变量:

  • label是用来代表协程状态机当中状态的;
  • result是用来存储当前挂起函数执行结果的;
  • mUser、mFriendList则是用来存储历史挂起函数执行结果的;
  • invokeSuspend这个函数,是整个状态机的入口,它会将执行流程转交给testCoroutine()进行再次调用。

接下来是要判断 testCoroutine 是不是初次运行,如果是初次运行,我们就要创建一个 TestContinuation 的实例对象。

// 代码段9

//                    ↓
fun testCoroutine(completion: Continuation<Any?>): Any? {
    ...
    val continuation = if (completion is TestContinuation) {
        completion
    } else {
        //                作为参数
        //                   ↓
        TestContinuation(completion)
    }
}

也就是:

  • invokeSuspend 最终会调用 testCoroutine,然后走到这个判断语句;
  • 如果是初次运行,会创建一个 TestContinuation 对象,completion 作为参数;
  • 这相当于用一个新的 Continuation 包装了旧的 Continuation;
  • 如果不是初次运行,直接将 completion 赋值给 continuation;
  • 这说明 continuation 在整个运行期间,只会产生一个实例,这能极大地节省内存开销(对比CallBack)。

接下来是几个变量的定义:

// 代码段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

上面的代码,分别代表了函数当中的临时变量、挂起函数执行结果,以及是否挂起的标志位。接着,我们来看看协程状态机的核心逻辑:

// 代码段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中。

上面这一大串文字和代码看着是不是有点晕?你可以再结合着来看看这个视频演示。

那到这里是不是就结束了呢?并不,因为这个动画仅演示了每个协程正常挂起的情况。如果协程并没有真正挂起呢?协程状态机会怎么运行?

协程未挂起的情况

要验证也很简单,我们将其中一个挂起函数改成伪挂起函数即可。

// 代码段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转换。

// 代码段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 分支里,协程状态机会直接进入下一个状态。

现在只剩最后一个问题了:

// 代码段14

if (suspendReturn == sFlag) {
} else {
    // 具体代码是如何实现的?
    //       ↓
    //go to next state
}

答案其实也很简单:如果你去看协程状态机的字节码反编译后的 Java,会看到很多 label。协程状态机底层字节码,是通过 label来实现这个 go to next state 的。由于 Kotlin 没有类似 goto 的语法,下面我用伪代码来表示 go to next state 的逻辑。

// 代码段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代码,它的可读性要差很多,也更难理解。

// 代码段16

// 看不懂也没关系,有个印象即可

@Nullable
public static final Object testCoroutine(@NotNull Continuation $completion) {
    Object $continuation;
    label37: {
        if ($completion instanceof <TestSuspendKt$testCoroutine$1>) {
            $continuation = (<TestSuspendKt$testCoroutine$1>)$completion;
            if ((((<TestSuspendKt$testCoroutine$1>)$continuation).label & Integer.MIN_VALUE) != 0) {
                ((<TestSuspendKt$testCoroutine$1>)$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 = ((<TestSuspendKt$testCoroutine$1>)$continuation).result;
            var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
            switch(((<TestSuspendKt$testCoroutine$1>)$continuation).label) {
                case 0:
                    ResultKt.throwOnFailure($result);
                    log("start");
                    ((<TestSuspendKt$testCoroutine$1>)$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)((<TestSuspendKt$testCoroutine$1>)$continuation).L$0;
                    ResultKt.throwOnFailure($result);
                    var10000 = $result;
                    break label30;
                case 3:
                    friendList = (String)((<TestSuspendKt$testCoroutine$1>)$continuation).L$1;
                    user = (String)((<TestSuspendKt$testCoroutine$1>)$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);
            ((<TestSuspendKt$testCoroutine$1>)$continuation).L$0 = user;
            ((<TestSuspendKt$testCoroutine$1>)$continuation).label = 2;
            var10000 = getFriendList(user, (Continuation)$continuation);
            if (var10000 == var6) {
                return var6;
            }
        }

        friendList = (String)var10000;
        log(friendList);
        ((<TestSuspendKt$testCoroutine$1>)$continuation).L$0 = user;
        ((<TestSuspendKt$testCoroutine$1>)$continuation).L$1 = friendList;
        ((<TestSuspendKt$testCoroutine$1>)$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 在实际开发中用的很少。

注意:Kotlin挂起函数反编译出来的Java代码,会因为实际开发环境的不同出现细微差异。随着Kotlin编译器的发展,将来可能会对这部分逻辑进一步优化,但它的核心状态机思想是不会轻易改变的。

好,到现在,我们就已经彻底弄懂挂起函数的实现原理了。接下来,我们就结合刚刚学习的内容,来进一步思考实战一下。

思考与实战

在上节课我曾提到过,Kotlin协程的源代码其实分为三层,其中基础层当中的“基础概念”尤为重要。那么,Kotlin官方为我们提供了哪些与挂起函数相关的基础元素呢?

我们首先想到的,肯定就是Continuation.kt,在这里面,确实也可以找到一些跟挂起函数相关的基础元素。

// 代码段17

public interface Continuation<in T> {
    public val context: CoroutineContext

    public fun resumeWith(result: Result<T>)
}

@Suppress("WRONG_MODIFIER_TARGET")
public suspend inline val coroutineContext: CoroutineContext
    get() {
        throw NotImplementedError("Implemented as intrinsic")
    }

在上面的代码中,我们最熟悉的就是Continuation这个接口了,除此之外,还有一个顶层的变量值得我们注意:suspend inline val coroutineContext。要知道,我们从来都是用suspend修饰函数的,从未见过suspend修饰变量的情况。

如果我们依葫芦画瓢,创建一个类似的顶层变量的话,编译器甚至会报错:

// 代码段18

// 报错
public suspend inline val test: CoroutineContext
    get() = TODO()

由此可见,suspend的这种用法只是一种特殊用法。结合“public suspend inline val”这几个关键字来看,我们其实可以大致推测出它的作用:它是一个只有在挂起函数作用域下,才能访问的顶层的不可变的变量。这里的inline,意味着它的具体实现会被直接复制到代码的调用处。

17讲思考题解答

为了验证我们前面的猜测,我们可以回过头来看看第17讲的思考题:

// 代码段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编译器来完成的。

// 代码段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 也有着紧密的联系,请问,你能找到具体的证据吗?

解答:挂起函数与 CoroutineContext 确实有着紧密的联系。每个挂起函数当中都会有Continuation,而每个Continuation当中都会有coroutineContext。并且,我们在挂起函数当中,就可以直接访问当前的coroutineContext。

KtHttp支持挂起函数

第18讲当中,我们并没有让KtHttp直接支持挂起函数,当时我们的做法是给KtCall扩展了一个await()方法,从而实现挂起函数调用的。

那么,经过这节课的学习,我们就可以来尝试让KtHttp直接支持挂起函数了,也就是我们可以这样来写代码:

interface ApiServiceV7 {

    @GET("/repo")
    // 1,挂起函数
    suspend fun reposSuspend(
        @Field("lang") lang: String,
        @Field("since") since: String
    ): RepoList
}



private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        // 省略
        return when {
        isSuspend(method) -> {
            // 2,支持挂起函数
        }
        isKtCallReturn(method) -> {
            // 省略
        }
        isFlowReturn(method) -> {
            // 省略
        }
        else -> {
            // 省略
        }
    }
}

// 3,判断是不是挂起函数
private fun isSuspend(method: Method) = method.kotlinFunction?.isSuspend ?: false


// 4,真正执行网络请求的方法
suspend fun <T: Any> 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<T>(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()这个挂起函数就行了。

private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        // 省略
    return when {
        isSuspend(method) -> {
            // 1,反射获取类型信息
            // 2,调用realCall()
        }
        isKtCallReturn(method) -> {
            // 省略
        }
        isFlowReturn(method) -> {
            // 省略
        }
        else -> {
            // 省略
        }
    }
}

所以,接下来我们要做的事情大致可以分为两个部分。

第一个部分,获取类型信息,准备请求网络,这个部分其实很简单。但在第二个部分“支持挂起函数”这里会遇到问题:

private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        // 省略
    return when {
        isSuspend(method) -> {
            // 支持挂起函数
            val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException()
            val call = okHttpClient!!.newCall(request)

            // 报错!!
            realCall<T>()
        }
        isKtCallReturn(method) -> {
            // 省略
        }
        isFlowReturn(method) -> {
            // 省略
        }
        else -> {
            // 省略
        }
    }
}

以上代码报错的原因也很容易理解,realCall()是一个挂起函数,它无法在普通函数里被调用!所以这里我们就面临了一个问题:如何在普通Kotlin函数当中调用挂起函数?

那么,我们首先可以想到的解决办法,就是强制类型转换:

private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        // 省略
    return when {
        isSuspend(method) -> {
            // 支持挂起函数
            val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException()
            val call = okHttpClient!!.newCall(request)

            val continuation = args.last() as? Continuation<T>
            // 1,将挂起函数类型转换成,带Continuation的类型,报错
            val func = ::realCall as (Call, Gson, Type, Continuation<T>?) -> Any?
            func.invoke(call, gson, genericReturnType, continuation)
        }
        isKtCallReturn(method) -> {
            // 省略
        }
        isFlowReturn(method) -> {
            // 省略
        }
        else -> {
            // 省略
        }
    }
}

请留意代码中的注释1,我们尝试使用“函数引用”的方式,将realCall()转换成了带有Continuation的函数类型,这样我们就可以通过传入Continuation,来调用realCall()这个挂起函数了。

不过,事与愿违,我们的方法并不能奏效,因为这行代码会报错,原因是realCall()带有泛型,而Kotlin暂时不支持“函数引用带泛型”的语法。

所以在这里,为了让这个Demo能运行起来,我们可以定义一个临时方法:

private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        // 省略
    return when {
        isSuspend(method) -> {
            // 支持挂起函数
            val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException()
            val call = okHttpClient!!.newCall(request)

            val continuation = args.last() as? Continuation<T>
            // 1,使用临时方法消除泛型
            val func = ::temp as (Call, Gson, Type, Continuation<T>?) -> Any?
            func.invoke(call, gson, genericReturnType, continuation)
        }
        isKtCallReturn(method) -> {
            // 省略
        }
        isFlowReturn(method) -> {
            // 省略
        }
        else -> {
            // 省略
        }
    }
}

suspend fun temp(call: Call, gson: Gson, type: Type) = realCall<RepoList>(call, gson, type)

在上面的代码中,我们使用了一个临时方法消除了泛型T,写死了返回值类型RepoList。这样的代码,在Demo当中是可以运行的,这从侧面也能印证我们上面代码中的类型转换是成功的。

fun main() = runBlocking {
    val data: RepoList = KtHttpV7.create(ApiServiceV7::class.java).reposSuspend(
        lang = "Kotlin",
        since = "weekly"
    )

    println(data)
}
/*
输出结果
正常
*/

不过,这种做法明显不具备普适性,为了让KtHttp支持所有类型的API请求,我们必须要想其他的办法。具体来说,我们可以这样做:

private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        // 省略
    return when {
        isSuspend(method) -> {
            // 支持挂起函数
            val genericReturnType = method.kotlinFunction?.returnType?.javaType ?: throw IllegalStateException()
            val call = okHttpClient!!.newCall(request)

            val continuation = args.last() as? Continuation<T>

            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<String>,而转换过后的函数返回值类型会变成“Any?”类型。
  • 当挂起函数经过反编译以后,它会变成由switch和label组成的状态机结构
  • 为了便于研究,课程里提供了大致等价的协程状态机代码:其中,when 表达式实现了协程状态机,而continuation.label 则代表了当前状态机的具体状态,continuation.label 改变一次,就代表了挂起函数被调用了一次;
  • 在一个挂起函数被调用的时候,它的返回值可能是具体的结果,也可能会是 CoroutineSingletons.COROUTINE_SUSPENDED,这时候就代表了这个函数被挂起了。

另外在这节课里,我们还进行了一次反思和实战,通过研究协程基础层当中的“suspend inline val coroutineContext”这个顶层变量,我们发现了挂起函数与协程上下文之间的紧密联系。并且,我们还灵活运用了这节课学到的知识,进一步改进了KtHttp,让它可以直接支持挂起函数。

你在自己的工作场景当中,其实也可以通过这样思考与实战的方式,来进一步强化所学和所得,甚至可以把输入转化成输出,把知识真正沉淀成你自己的东西。

思考题

我们都知道挂起函数是Kotlin协程里才有的概念,请问,Java代码中可以调用Kotlin的挂起函数吗?比如,下面这个函数,我们可以在Java当中调用吗?

object SuspendFromJavaExample {
    // 在Java当中如何调用这个方法?
    suspend fun getUserInfo(id: Long):String {
        delay(1000L)
        return "Kotlin"
    }
}

精选留言

  • 荷兰小猪8813

    2022-04-12 00:09:30

    我刚看了字节码,总结如下:

    可以看出:

    1、testCoroutine 内部会创建一个匿名内部类 wrapper 实例,继承了 ContinuationImpl 类,将参数 Continuation var0 包在的内部;并且会将该 wrapper 实例作为参数传递给内部的挂起函数 getUserInfo... ...;

    ​ ContinuationImpl 继承了 BaseContinuationImpl,间接实现了 Continuation 接口;

    ​ 协程的恢复,就是通过 BaseContinuationImpl.resumeWith 实现,resumeWith 又会执行 invokeSuspend 方法,将目标结果返回;

    ​ invokeSuspend 是协程恢复的入口,其内部又会执行挂起函数 testCoroutine,而此时的参数是 wrapper 实例;



    2、匿名内部类 wrapper 实例内部的 label 变量表示:状态机的状态,而状态的流转逻辑是由挂起函数 testCoroutine 控制;

    ​ 匿名内部类 wrapper 实例内部的 result 变量表示:目标结果;



    3、匿名内部类 wrapper 实例只会创建一次!!

    ​ 当第一次调用 testCoroutine 的时候,参数 Continuation var0 是外部的 Continuation 实例(外部协程的 Continuation/外部挂起函数的 ContinuationWrapper 实例)

    ​ 当挂起后恢复,再次调用的时候,参数 Continuation var0 是第一次创建的 匿名内部类 wrapper 实例;



    4、testCoroutine 状态机结构是通过 switch 语句 + label 代码段嵌套

    ​ testCoroutine 里的原本的代码,被拆分到状态机里各个状态中,分开执行;

    ​ 每次挂起函数执行完,恢复后,都会检查是否发生异常;

    ​ 如果一个挂起函数挂起,它的返回值会是 CoroutineSingletons.COROUTINE_SUSPENDED;
    作者回复

    不错的总结~

    2022-04-15 00:23:00

  • Geek_Adr

    2022-04-10 10:55:36

    // JAVA 调用 挂起函数
    public static void main(String[] args) throws InterruptedException {
    SuspendFromJavaExample.INSTANCE.getUserInfo(111, new Continuation<String>() {
    @NotNull
    @Override
    public CoroutineContext getContext() {
    return (CoroutineContext) Dispatchers.getDefault();
    }

    @Override
    public void resumeWith(@NotNull Object o) {
    System.out.printf("suspend fun getUserInfo result => %s\n", o);
    }
    });
    Thread.sleep(2000); // 有点挫,等结果
    }
    作者回复

    不错~

    BTW:注释有意思,哈哈~

    2022-04-15 00:46:05

  • Shanks-王冲

    2022-03-30 14:42:27

    思考题:后知后觉
    1. 最终还是运行了一遍,返回的是CoroutineSingletons.COROUTINE_SUSPENDED,即惊讶,又啊哈
    2. 协程非阻塞,体现在suspend func立即返回个CoroutineSingletons.COROUTINE_SUSPENDED,然后继续忙其他的;于是伪suspend func,则直接返回结果

    *关于如何在Java中调用:之前有课程提到,在Java中的getContext()中可考虑传入EmptyCoroutineContext.INSTANCE
    作者回复

    总结很到位~

    2022-04-01 19:55:21

  • Allen

    2022-03-21 11:33:32

    关于思考题的思考:

    在 Java 中是可以调用挂起函数的,当挂起函数通过 Kotlin 编译器进行 CPS 转换后,对应的挂起函数就被转换成了带 Callback 参数(Continuation 接口)的普通函数,只要传入 Continuation 接口的实现就可以了。
    作者回复

    说的很好,有代码就更好了

    2022-03-23 18:09:37

  • Geek_48edaa

    2022-12-05 16:22:05

    请问老师,关于kotlin源码总是找不到实现类,只能通过ctrl shift + F凭运气找。运气好能搜到,运气不好就搜不到。

    有时候找到了也看不懂,毫无思路。其实不管是学Android Framework,还是kotlin协程我都有这样的问题。就是只能对照着文章跟博客去学习,一旦没了指引,自己完全没能力自学了。
  • 2022-05-04 23:47:33

    我不是很明白是怎么恢复协程,也就是调用resumewith,java反编译过去也没看到调用
    作者回复

    这里确实隐藏的比较深,需要写一个完整的例子,同时结合反编译代码+协程源码来分析。

    2022-05-08 23:46:16

  • 神秘嘉Bin

    2022-03-24 12:57:39

    HelloSuspendTestKt.hello(new Continuation<Integer>() {
    @NotNull
    @Override
    public CoroutineContext getContext() {
    return EmptyCoroutineContext.INSTANCE;
    }

    @Override
    public void resumeWith(@NotNull Object o) {
    // TODO
    }
    });
    作者回复

    不错~

    2022-03-29 10:35:43

  • anmi

    2024-04-13 15:33:07

    挂起函数的精髓:
    1.挂起函数内部通过when语句实现了状态机。
    2.挂起函数内部定义了ContinuationImpl的实现类,在里面储存自身逻辑特有的状态机的状态、中间计算结果、result。
    3.挂起函数与内部定义的ContinuationImpl的实现类互相持有引用。挂起函数在第一次执行时创建了ContinuationImpl实现类的实例,ContinuationImpl实现类的invokeSupend方法可以再次调用挂起函数。
    4.挂起函数可以通过continuation参数的invokeSuspend方法,再次调用父挂起函数,把自己的异步逻辑执行完成后的结果传递给它。
    5.挂起函数是无状态的。状态都在ContinuationImpl实现类的实例里。
    6.挂起函数在每个状态机分支中,在调用子挂起函数前,都会将ContinuationImpl实现类的label+1,这样当子挂起函数异步逻辑执行完毕,通过invokeSuspend再次调用挂起函数,传递会子挂起函数的异步逻辑执行结果时,挂起函数会走下一个状态机分支。
  • anmi

    2024-04-13 15:18:43

    1.每一个挂起函数都会有一个continuation参数。它的里面封装了调用这个挂起函数的协程状态机的状态、中间计算结果,result。另外还有一个invokeSuspend方法。
    2.每一个挂起函数都会内部定义一个ContinuationImpl的实现类,用于封装自己的协程状态机状态、中间计算结果、result,另外还有一个invokeSuspend方法,这个方法在被调用时会传入子挂起函数的计算结果,以这个结果为参数再次调用这个挂起函数。
    3.每一个挂起函数在一开始是,都会创建一个自身内部定义的ContinuationImpl的实现类的实例,该实例会把函数的continuation参数包裹起来。这个被包裹起来的参数来自于父挂起函数,它用于在当前挂起函数的异步逻辑执行完成后,调用continuation的invokesuspend方法。
    4.当continuation的invokesuspend方法被调用时,此方法中封装的父挂起函数将被重新调用,传入参数为当前挂起函数的异步逻辑的结果。
    5.每个挂起函数内部都有一个when状态机。每次执行一个分支都会将label+1,这样,当子挂起函数异步逻辑执行完成后,通过continuation的invokesuspend方法再次调用挂起函数时,它将执行when的下一个分支。
    6.当子挂起函数通过continuation的invokesuspend方法再次执行挂起函数时,传入的continuation就是初次执行挂起函数时创建的,也是调用子挂起函数时传入的,因此不会再创建新的实例,做无意义的包裹。
    7.当挂起函数的when分支中执行到调用子挂起函数时,会传入当前的ContinuationImpl实现类的实例作为continuation参数,子挂起函数会在内部创建自身的ContinationImpl实现类的实例,并包裹continuation参数。
    8.当挂起函数的when分支中执行到调用子挂起函数时,如果子挂起函数是真正的挂起函数,返回值会表示它已经挂起,此时挂起函数也同样返回该返回值,通知自己的调用方:我挂起啦!有异步逻辑在执行,你等着!等到异步逻辑执行完,我的那个ContinuationImpl实现类的实例的invokeSuspend方法会被调用,然后再次调用我,就走下一个状态的逻辑啦!
    9.如果挂起函数的when分支调用子挂起函数时,子挂起函数是伪挂起函数,返回值就直接是执行结果,挂起函数拿到它,处理处理,然后自己跳转下一个状态。
  • anmi

    2023-11-14 10:09:44

    怪,太怪了!
    一开始没注意,转头一看,一个函数里面定义了一个内部类?!
  • Geek_48edaa

    2022-12-05 17:31:56

    “invokeSuspend 最终会调用 testCoroutine,然后走到这个判断语句;”原文这句话有问题吧?

    testCoroutine方法需要createCoroutine或者startCoroutine来启动协程,匿名内部类初始化是在create方法中做的。是testCoroutine的内容被系统生成为状态机,BaseContinuationImpl中调用invokeSuspend会进行状态机流转,而不是“invokeSuspend 最终会调用 testCoroutine”。
  • H.ZWei

    2022-04-28 07:29:05

    有点类似rxjava操作符的嵌套
    作者回复

    是的

    2022-04-29 23:14:08

  • 荷兰小猪8813

    2022-04-11 23:36:53

    1、invokeSuspend 不是协程的入口,是 resumeWith 的入口,
    恢复协程是调用的 continuation.resumeWith,这个会调用 invokeSuspend
    作者回复

    可以这么理解。

    2022-04-15 00:22:17

  • 荷兰小猪8813

    2022-04-11 22:22:34

    这样看,每个挂起函数,都会创建一个继承了 ContinuationImpl 的匿名内部类对象,把传进来的 Continuation 包起来~~~
    作者回复

    粗略上,可以这么理解。

    2022-04-15 00:21:17

  • 荷兰小猪8813

    2022-04-11 08:39:47

    withContext 为啥没有创建新的协程呢???我看源码都有对应的 job 和 coroutome.start
    作者回复

    协程数量是否加1,其实取决于newCoroutineContext()这个方法是否调用。

    2022-04-15 00:37:49

  • 杨小妞

    2022-03-29 11:43:50

    以下代码: 挂起函数b中的c、d之间不存在回调关系,而挂起函数a的b,a、b之间存在回调关系?可以这么理解吗?
    suspend fun a() {
    b()
    }

    suspend fun b() {
    val c = c()
    val d = d()
    }

    suspend fun c(): String {
    return withContext(Dispatchers.IO) {
    "c"
    }
    }

    suspend fun d(): String {
    return withContext(Dispatchers.IO) {
    "d"
    }
    }
    作者回复

    如果我们没理解错你的问题的话,你说的可能是Continuation的回调?如果是的话,那么你的结论可能刚好就反过来了。具体原因,你也可以在加餐5当中找到答案哈~

    2022-04-01 20:10:43

  • 杨小妞

    2022-03-29 11:41:37

    有个疑问:TestContinuation维护一个状态机来执行挂起函数,那么运行结果什么时候回调出去呢?是在最后一个swithc分支吗?例如testCoroutine是被其他挂起函数调用
    作者回复

    这个问题你可以去加餐5当中找到答案哈~

    2022-04-01 20:00:04

  • ACE_Killer09

    2022-03-23 21:35:36

    挂起函数中在线程中的执行结果是怎么返回的?比如andorid中子线程的结果怎么给到主线程呢?
    作者回复

    这个我在后面的加餐5有提到,你可以去看看。

    2022-03-29 10:35:28

  • 魏全运

    2022-03-23 09:31:04

    请问kotlin是怎么执行下一个状态机逻辑的呢?
    作者回复

    When每一个分支都会对label+1

    2022-03-23 17:56:53