协程(09) | 实现一个简易Retrofit

5,372 阅读6分钟

前言

作为Android开发,大名鼎鼎的Retrofit网络请求库肯定都用过,而且在Kotlin更新协程后,Retrofit也第一时间更新了协程方式、Flow方式等编码模式,这篇文章我们利用前面的学习知识,尝试着实现一个建议版本的Retrofit,然后看看如何利用挂起函数,来以同步的方式实现异步的代码

正文

Retrofit涉及的知识点还是蛮多的,包括自定义注解、动态代理、反射等知识点,我们就来复习一下,最后再看如何使用协程来把我们不喜欢的Callback给消灭掉。

定义注解

Retrofit一样,我们定义俩个注解:

/**
* [Field]注解用在API接口定义的方法的参数上
* */

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Field(val value: String)
/**
 * [GET]注解用于标记该方法的调用是HTTP的GET方式
 * */

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class GET(val value: String)

这里我们定义俩个注解,Field用来给方法参数设置,GET用来给方法设置,表明它是一个HTTPGET方法。

定义ApiService

Retrofit一样,来定义一个接口文档,里面定义我们需要使用的接口:

/**
 * [ApiService]类定义了整个项目需要调用的接口
 * */
interface ApiService{

    /**
     * [reposAsync]用于异步获取仓库信息
     *
     * @param language 要查询的语言,http真实调用的是[Field]中的lang
     * @param since 要查询的周期
     *
     * @return
     * */
    @GET("/repo")
    fun reposAsync(
        @Field("lang") language: String,
        @Field("since") since: String
    ): KtCall<RepoList>

    /**
     * [reposSync]用于同步调用
     * @see [reposSync]
     * */
    @GET("/repo")
    fun reposSync(
        @Field("lang") language: String,
        @Field("since") since: String
    ): RepoList
}

这里我们查询GitHub上某种语言近期的热门项目,其中reposAsync表示异步调用,返回值类型是KtCall<RepoList>,而reposSync表示同步调用,这里涉及的RepoList就是返回值的数据类型:

data class RepoList(
    var count: Int?,
    var items: List<Repo>?,
    var msg: String?
)

data class Repo(
    var added_stars: String?,
    var avatars: List<String>?,
    var desc: String?,
    var forks: String?,
    var lang: String?,
    var repo: String?,
    var repo_link: String?,
    var stars: String?
)

KtCall则是用来承载异步调用的回调简单处理:

/**
 * 该类用于异步请求承载,主要是用来把[OkHttp]中返回的请求值给转换
 * 一下
 *
 * @param call [OkHttp]框架中的[Call],用来进行网络请求
 * @param gson [Gson]的实例,用来反序列化
 * @param type [Type]类型实例,用来反序列化
 * */
class KtCall<T: Any>(
    private val call: Call,
    private val gson: Gson,
    private val type: Type
){

    fun call(callback: CallBack<T>): Call{
        call.enqueue(object : okhttp3.Callback{
            override fun onFailure(call: Call, e: IOException) {
                callback.onFail(e)
            }

            override fun onResponse(call: Call, response: Response) {
                try {
                    val data = gson.fromJson<T>(response.body?.string(),type)
                    callback.onSuccess(data)
                }catch (e: java.lang.Exception){
                    callback.onFail(e)
                }
            }
        })
        return call
    }
}

在这里定义了一个泛型类,用来处理T类型的数据,异步调用还是调用OkHttpCallenqueue方法,在其中对OkHttpCallback进行封装和处理,转变为我们定义的Callback类型:

/**
 * 业务使用的接口,表示返回的数据
 * */
interface CallBack<T: Any>{
    fun onSuccess(data: T)
    fun onFail(throwable:Throwable)
}

这里我们暂时只简单抽象为成功和失败。

单例Http工具类

再接着,我们模仿Retrofit,来使用动态代理等技术来进行处理:

/**
 * 单例类
 *
 * */
object KtHttp{

    private val okHttpClient = OkHttpClient
        .Builder()
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BASIC
        })
        .build()
    private val gson = Gson()
    val baseUrl = "https://trendings.herokuapp.com"

    /**
     * 利用Java的动态代理,传递[T]类型[Class]对象,可以返回[T]的
     * 对象。
     * 其中在lambda中,一共有3个参数,当调用[T]对象的方法时,会动态
     * 代理到该lambda中执行。[method]就是对象中的方法,[args]是该
     * 方法的参数。
     * */
    fun <T: Any> create(service: Class<T>): T {
        return Proxy.newProxyInstance(
            service.classLoader,
            arrayOf(service)
        ){ _,method,args ->
            val annotations = method.annotations
            for (annotation in annotations){
                if (annotation is GET){
                    val url = baseUrl + annotation.value
                    return@newProxyInstance invoke<T>(url, method, args!!)
                }
            }
            return@newProxyInstance null
        } as T
    }

    /**
     * 调用[OkHttp]功能进行网络请求,这里根据方法的返回值类型选择不同的策略。
     * @param path 这个是HTTP请求的url
     * @param method 定义在[ApiService]中的方法,在里面实现中,假如方法的返回值类型是[KtCall]带
     * 泛型参数的类型,则认为需要进行异步调用,进行封装,让调用者传入[CallBack]。假如返回类型是普通的
     * 类型,则直接进行同步调用。
     * @param args 方法的参数。
     * */
    private fun <T: Any> invoke(path: String, method: Method, args: Array<Any>): Any?{
        if (method.parameterAnnotations.size != args.size) return null

        var url = path
        val paramAnnotations = method.parameterAnnotations
        for (i in paramAnnotations.indices){
            for (paramAnnotation in paramAnnotations[i]){
                if (paramAnnotation is Field){
                    val key = paramAnnotation.value
                    val value = args[i].toString()
                    if (!url.contains("?")){
                        url += "?$key=$value"
                    }else{
                        url += "&$key=$value"
                    }
                }
            }
        }

        val request = Request.Builder()
            .url(url)
            .build()
        val call = okHttpClient.newCall(request)
        //泛型判断
        return if (isKtCallReturn(method)){
            val genericReturnType = getTypeArgument(method)
            KtCall<T>(call, gson, genericReturnType)
        } else {
            val response = okHttpClient.newCall(request).execute()

            val genericReturnType = method.genericReturnType
            val json = response.body?.string()
            Log.i("zyh", "invoke: json = $json")
            //这里这个调用,必须要传入泛型参数
            gson.fromJson<Any?>(json, genericReturnType)
        }
    }

    /**
     * 判断方法返回类型是否是[KtCall]类型。这里调用了[Gson]中的方法。
    */
    private fun isKtCallReturn(method: Method) =
        getRawType(method.genericReturnType) == KtCall::class.java


    /**
     * 获取[Method]的返回值类型中的泛型参数
     * */
    private fun getTypeArgument(method: Method) =
        (method.genericReturnType as ParameterizedType).actualTypeArguments[0]
}

上面的代码主要分为俩个部分,第一部分使用Java的动态代理类Porxy,可以通过create方法创建一个接口对象。调用该接口对象的方法,会被代理到lambda中进行处理,在lambda中我们对有GET修饰的方法进行额外处理。

第二部分就是方法的拼接和调用处理,先是针对Field注解修饰的方法参数,给拼接到url中,然后就是重点地方,判断方法的返回值类型,是否是KtCall类型,如果是的话,就认为是异步调用,否则就是同步调用。

对于异步调用,我们封装为一个KtCall的对象,而对于同步调用,我们可以直接利用Gson来解析出我们希望的数据。

Android客户端测试

这样我们就完成了一个简易的既有同步又有异步调用的网络请求封装库,我们写个页面调用一下如下:

//同步调用
private fun sync(){
    thread {
        val apiService: ApiService = KtHttp.create(ApiService::class.java)
        val data = apiService.reposSync(language = "Kotlin", since = "weekly")
        runOnUiThread {
            findViewById<TextView>(R.id.result).text = data.toString()
            Toast.makeText(this, "$data", Toast.LENGTH_SHORT).show()
        }
    }
}
//异步调用
private fun async(){
    KtHttp.create(ApiService::class.java).reposAsync(language = "Java", since = "weekly").call(object : CallBack<RepoList>{
        override fun onSuccess(data: RepoList) {
            runOnUiThread {
                findViewById<TextView>(R.id.result).text = data.toString()
                Toast.makeText(this@MainActivity, "$data", Toast.LENGTH_SHORT).show()
            }
        }

        override fun onFail(throwable: Throwable) {
            runOnUiThread {
                findViewById<TextView>(R.id.result).text = throwable.toString()
                Toast.makeText(this@MainActivity, "$throwable", Toast.LENGTH_SHORT).show()
            }
        }
    })
}

经过测试,这里代码可以正常执行。

协程小试牛刀

在前面我们说过挂起函数可以用同步的代码来写出异步的效果,就比如这里的异步回调,我们可以使用协程来进行简单改造。

首先,想把Callback类型的方式改成挂起函数方式的,有2种方法。第一种是不改变原来代码库的方式,在Callback上面套一层,也是本篇文章所介绍的方法。第二种是修改原来代码块的源码,利用协程的底层API,这个方法等后面再说。

其实在原来Callback上套一层非常简单,我们只需要利用协程库为我们提供的2个顶层函数即可:

/**
 * 把原来的[CallBack]形式的代码,改成协程样式的,即消除回调,使用挂起函数来完成,以同步的方式来
 * 完成异步的代码调用。
 *
 * 这里的[suspendCancellableCoroutine] 翻译过来就是挂起可取消的协程,因为我们需要结果,所以
 * 需要在合适的时机恢复,而恢复就是通过[Continuation]的[resumeWith]方法来完成。
 * */
suspend fun <T: Any> KtCall<T>.await() : T =
    suspendCancellableCoroutine { continuation ->
        //开始网络请求
        val c = call(object : CallBack<T>{
            override fun onSuccess(data: T) {
                //这里扩展函数也是奇葩,容易重名
                continuation.resume(data)
            }

            override fun onFail(throwable: Throwable) {
                continuation.resumeWithException(throwable)
            }
        })
        //当收到cancel信号时
        continuation.invokeOnCancellation {
            c.cancel()
        }
}

这里我们推荐使用suspendCancelableCoroutine高阶函数,听名字翻译就是挂起可取消的协程,我们给KtCall扩展一个挂起方法await,在该方法中,我们使用continuation对象来处理恢复的值,同时还可以响应取消,来取消OkHttp的调用。

这里注意的就是resume使用的是扩展函数,与之类似的还有一个suspendCoroutine方法,这个方法无法响应取消,我们不建议使用。

在定义完上面代码后,我们在Android使用一下:

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

可以发现在这种情况下,我们就可以使用同步的方式写出了异步代码,由于挂起函数的特性,下面那行UI操作会等到挂起函数恢复后才会执行。

总结

本篇文章主要是介绍了一些常用知识点,也让我们对Retrofit的各种方法返回类型兼容性有了一定了解,最后我们使用了在不改变原来代码库的情况下,利用封装一层的方式,来实现以同步的代码写异步的形式。

本篇文章代码地址: github.com/horizon1234…