协程(16) | 优雅地实现一个挂起函数

7,014 阅读7分钟

前言

不知道你有没有发现,在之前文章中,我们虽然定义了带suspend关键字的挂起函数,但是里面的实现我们一般都是调用其他挂起函数,是协程库提供的,或者第三方库实现的,比如我们熟知的Retrofit库,就可以直接在ApiService中定义挂起函数。

那我们可以自己实现一个挂起函数吗?其实在# 协程(09) | 实现一个简易Retrofit这篇文章中,我们就把Callback转换为了挂起函数,当时是说这种适合没有权限修改第三方库的情况,那现在我们有权限修改源码了,如何优雅地实现挂起函数呢?

本篇文章就来扩展之前做的Retrofit,我们来看看从定义一个挂起函数,到实现,到调用这一整个流程如何实现。

正文

由于代码是在前面项目上进行修改和扩展的,所以强烈建议查看文章:# 协程(09) | 实现一个简易Retrofit# 协程(15) | 挂起函数原理解析,这俩篇文章一个是代码的之前进度,好做比较,另一个是挂起函数的原理,方便本篇文章的开始。

Continuation理解

在前面挂起函数原理解析中,我们分析过,挂起函数经过CPS转换后,它就是一个状态机模型。在一个挂起函数里,调用N个挂起函数,会产生N+1个分支,通过多次调用自己,把每个挂起函数的值都保存到一个唯一的Continuation对象中,从而节省内存。

通过原理后,我们再来看看这个Continuation接口的定义:

/**
 * Interface representing a continuation after a suspension point 
 * that returns a value of type `T`.
 */
public interface Continuation<in T> {

    public val context: CoroutineContext

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

它是一个接口,表示挂起点后的延续,返回类型为T的值。

什么是接口,接口可以看成是一种通用的抽象和封装,而这里的Continuaiton接口则是对挂起函数的一种行为封装。即所有挂起函数,它都会有一个挂起点,然后该挂起点后面的协程代码就是延续(continuation),该挂起函数恢复时会携带T类型的值,这么一看Continuaiton的泛型参数也就很好理解了。

再接着看,由于挂起函数需要恢复的行为,所以接口中定义和抽象了resumeWith方法,而且还需要当前协程的上下文context

这里说一个特殊的实现,我们在平时使用挂起函数时,好像从来没有使用过这个continuation,但是可以在挂起函数中访问协程上下文,这是咋做到的呢?

比如下面代码:

suspend fun testCoroutine() = coroutineContext

在该挂起函数中,我们可以访问协程上下文,可以发现定义如下:

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

这居然是一个suspend inline类型的变量,不必惊讶,这种写法是Kotlin编译器帮我们做的,我们自己无法实现,通过这种方法,我们再反编译一下上面代码:

public static final Object testCoroutine(@NotNull Continuation $completion) {
   return $completion.getContext();
}

就可以清晰地发现,我们调用的其实还是Continuation对象的协程上下文,知识协程框架帮我们做了省略。

实现挂起函数

在更深入理解了Continuation接口后,我们来实现一个挂起函数。

首先是直接在ApiService中定义挂起函数:

/**
 * [reposSuspend]是挂起函数,这里使用直接定义
 * 和实现挂起函数的方式
 * */
@GET("/repo")
suspend fun reposSuspend(
    @Field("lang") language: String,
    @Field("since") since: String
): RepoList

然后在invoke中,和处理Flow分支一样,我们再加个分支来处理suspend函数:

private fun <T : Any> invoke(path: String, method: Method, args: Array<Any>): Any? {
        if (method.parameterAnnotations.size != args.size) return null

         ...
        //类型判断
        return when {
            isSuspend(method) -> {
               ...
            }
            isKtCallReturn(method) -> {
                ...
            }
            isFlowReturn(method) -> {
               ...
            }
            else -> {
                ...
            }
        }
    }
/**
 * 判断方法是否是[suspend]方法
 * */
private fun isSuspend(method: Method) =
    method.kotlinFunction?.isSuspend ?: false

这里判断方法是否是挂起函数,需要用到Kotlin的反射相关API。

这里我们就可以处理核心逻辑了,当是挂起函数时如何实现,首先我们定义一个realCall方法,在该方法中我们实现挂起函数:

/**
 * 该方法是[suspend]方法,用于实现挂起函数,其实在内部也调用了一个挂起函数,
 * 这里的重点是[Continuation]参数,利用该参数,返回挂起函数恢复的值。
 *
 * @param call [OkHttp]的[call]对象,用于网络请求。
 * @param gson [Gson]的对象,用于反序列化实例
 * @param type [Type]的对象,它是区别与[Class],是真正表示一个类的类型
 * */
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: Response) {
                try {
                    val t = gson.fromJson<T>(response.body?.string(), type)
                    continuation.resume(t)
                } catch (e: java.lang.Exception){
                    continuation.resumeWithException(e)
                }
            }
        })

        continuation.invokeOnCancellation {
            call.cancel()
        }
}

在该方法中,我们通过调用suspendCancellableCoroutine方法来实现一个挂起函数,其中就使用了对外暴露的continuation对象,来返回该挂起函数恢复时所返回的值。

那么现在就剩最后一步了,凑齐该方法所需要的参数,然后调用它,我们在invoke方法中如下写:

isSuspend(method) -> {
    //反射获取类型信息
    val genericReturnType = method.kotlinFunction?.returnType?.javaType
        ?: throw java.lang.IllegalStateException()
    //调用realCall方法
    //该方法会报错
    realCall<T>(call, gson,genericReturnType)
}

这里会发现我们在普通的invoke方法中根本无法调用挂起函数realCall,这里要如何做呢?

通过上一篇挂起函数原理的分析,我们知道编译器会解析suspend关键字,经过CPS转换后,函数类型会变化,那我们使用函数引用,来获取realCall的非suspend函数类型,然后再调用,代码如下:

//反射获取类型信息
val genericReturnType = method.kotlinFunction?.returnType?.javaType
    ?: throw java.lang.IllegalStateException()
val continuation = args.last() as? Continuation<T>
Log.i(KtHttp.javaClass.simpleName, "invoke: continuation : $continuation")
//将挂起函数类型转换成,带Continuation的类型
val func = ::realCall as (Call,Gson,Type,Continuation<T>?) -> Any?
//这里依旧无法调用
func.invoke(call, gson,genericReturnType,continuation)

这里无法使用的原因是func的函数类型,它是带泛型T的,目前Kotlin还不支持带泛型的函数类型,那只能想办法把T给消除掉了:

//定义一个临时函数,调用规定了泛型类型
suspend fun temp(call: Call, gson: Gson, type: Type) = realCall<RepoList>(call, gson, type)
//反射获取类型信息
val genericReturnType = method.kotlinFunction?.returnType?.javaType
    ?: throw java.lang.IllegalStateException()
val continuation = args.last() as? Continuation<T>
Log.i(KtHttp.javaClass.simpleName, "invoke: continuation : $continuation")
//这样func可以完全获取temp函数的引用
val func = ::temp as (Call,Gson,Type,Continuation<T>?) -> Any?
func.invoke(call, gson,genericReturnType,continuation)

上面我们通过定义temp函数来消除了T,但是却有问题,代码不具有普遍性了,当函数返回值为其他类型,则无法执行。

正确的做法是通过反射,可以拿到realCall函数的函数引用,代码如下:

isSuspend(method) -> {
    //反射获取类型信息
    val genericReturnType = method.kotlinFunction?.returnType?.javaType
        ?: throw java.lang.IllegalStateException()
    //打印看看所有的参数
    Log.i(KtHttp.javaClass.simpleName, "invoke: args : ${args.toList()}")
    //创建continuation实例
    val continuation = args.last() as? Continuation<T>
    Log.i(KtHttp.javaClass.simpleName, "invoke: continuation : $continuation")
    //通过反射拿得realCall方法
    val func = KtHttp::class.getGenericFunction("realCall")
    func.call(this, call, gson, genericReturnType, continuation)
}
/**
 * 获取方法的反射对象
 * */
private fun KClass<*>.getGenericFunction(name: String): KFunction<*> {
    return members.single { it.name == name } as KFunction<*>
}

通过反射,我们可以拿到realCall的方法,然后通过反射调用,我们就可以在非挂起函数中调用挂起函数了。

注意上面的打印,我们打印了continuation以及所有参数,我们先来执行一下上面代码,如下:

findViewById<TextView>(R.id.suspendCall).setOnClickListener {
    lifecycleScope.launch {
        val data = KtHttp.create(ApiService::class.java).reposSuspend(language = "Kotlin", since = "weekly")
        findViewById<TextView>(R.id.result).text = data.toString()
    }
}

这里我们就可以使用我们实现的挂起函数来以同步的方式写出异步的代码了。

这里你或许会有疑问,我们传递了2个参数,但是上面打印到底是什么呢?打印如下:

invoke: args : [Kotlin, weekly, 
Continuation at com.example.wan.MainActivity$onCreate$6$1.invokeSuspend(MainActivity.kt:70)]

会发现在实际运行时,由于该方法是挂起函数,根据上一篇文章我们说的内容,会在方法后面增加一个Continuation类型的额外参数。利用这个额外的参数,我们就可以在非挂起函数中,调用挂起函数了。

总结

本篇文章,和我们日常开发关系非常大,做个简单总结:

  • 先是Continuation接口的抽象,它表示挂起函数在协程某个挂起点的挂起和恢复,是一种行为的抽象。
  • 随后我们自己实现了挂起函数,通过高阶函数suspendCancellableCoroutine暴露的continuation实例,我们可以设置恢复时的数据。
  • 最后就是如何在非挂起函数调用我们的挂起函数呢?方法就是根据前一节说的挂起函数CPS后的本质函数类型来调用,具体方法必须得是通过反射。
  • 关于continuation这个对象,会在调用挂起函数时自动添加,它的值打印的话,会如上面所示,显示在哪里挂起和延续。

本篇文章所涉及的代码:github.com/horizon1234…**