还有比Retrofit更简单易用的网络请求方案吗?

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:[https://www.jianshu.com/p/dc9f28f63666)

前言

Retrofit是当前业内非常流行的网络请求框架,它简单易用,几乎是Android开发必备。同时它使用到了大量的设计模式,其代码值得我们仔细研读,其设计思想值得我们深入思考。。。

为了防止大佬们喷我,先吹一波Retrofit,接下来探讨一下在Kotlin+协程环境下比Retrofit更简单易用的封装方式。

首先提出两个问题:

  1. 有必要对网络请求做一层包装吗?

    个人感觉很有必要,就拿Retrofit来说对业务代码的侵入是比较大的,从长远的角度来考虑,没有任何框架敢说自己是yyds,一旦有一天出了新技术想要换框架,面对那么多的业务代码不由得脑海中就会有一万头羊驼奔腾而过。

  2. 如果做包装层,那Retrofit还有使用的必要吗?

    首先为什么要使用Retrofit?它相较于OkHttp主要做了三件事情:1,线程切换。2,数据解析。3,请求参数可配置化。

    对于线程切换我们用Kt协程可以很容易实现。对于数据解析如果能够拿到返回结果的Type,也就是一行代码的事情。对于请求参数,通过简单的封装也可以很方便设置。

因此本文的目的就是扔掉Retrofit,借助Kt协程对OkHttp做一层包装。

先看一下最终使用效果:

viewModelScope.launch(Dispatchers.IO) { 
    // 感谢玩安卓提供的api
    val url = "https://wanandroid.com/wxarticle/chapters/json"
    // 请求网络返回数据
    val result = HttpUtils.get<List<Chapters>>(url)
}

明确目的

对于一个网络请求工具我们想要的无非就是我们把参数传进去,然后把期望的结果返回来。

mid.png

此时心中应该大概有了想要的效果:

HttpUtils.get(url, param, header, object: Callback<T>{ 
    onSuccess{}
    onError{}
})

HttpUtils.post(url, body, header, object: Callback<T>{ 
    onSuccess{}
    onError{}
})

要传的参数包括url,Query参数,Body,Header等,另外为了使其返回数据解析后的结果,还需要传结果类型的Type。其他参数都好说,唯独返回结果的Type该怎么传递呢?接下来的两个方案都是围绕这个问题展开的。

方案一

首先定义一下返回结果:

data class ChaptersResp() {
    var data = arrayListOf<Chapters>(),
    var errorCode: Int,
    var errorMsg: String
}

data class Chapters(
    var courseId: String,
    var id: Int,
    var name: String,
    var order: Int
)

前面已经提到,重点在于如何传递期望返回类型的Type。

如果是对象如ChaptersResp,我们可以通过ChaptersResp.class作为参数传递,但是项目中一般都会有一个BaseResp,因此只需要定义一个Chapters就可以了,那问题来了,如何传递List<Chapters>呢?总不能传个(List<Chapters>).class吧。

1,Object.class方式

如果非要通过这种Object.class方式传递,有两种方式:1,传递完整对象的class,如:ChaptersResp.class。2,从方法上解决,将单体对象和集合对象分开,如返回结果是单体对象就用HttpUtils.get(),集合对象就用HttpUtils.getList(),然后在方法内部进行区分。如下:

// 1,传参上解决:传整体对象
HttpUtils.get(url, param, ChaptersResp.class, object: HttpCallback<ChaptersResp> {
    onSuccess{}
    onError{}
})

// 2,从方法上解决:传递Chapters的class,然后在getList方法中解析成List
HttpUtils.getList(url, param, Chapters.class, object: HttpCallback<List<Chapters>> {
    onSuccess{}
    onError{}
})

这应该是最low的方式了,只有新手才会这么搞吧,如果有更好的方案,这种当然不可取。

从上面的方法可以看出在回调中通过泛型已经添加了期望返回的类型。那能否从其中获取呢?

2,泛型中获取

在泛型类中可以通过getGenericSuperclass() 获取当前类表示的实体(类,接口,基本类型或void)的直接父类的Type,通过getActualTypeArguments()可以获取参数数组。如下:

public abstract class HttpCallback<T> implements CallBack<T> {

    @Override
    public void onNext(String data) {
        // 获取GenericSuperclass
        Type type = getClass().getGenericSuperclass();
        if (type instanceof ParameterizedType) {
            // 获取泛型的Type数组
            Type[] types = ((ParameterizedType) type).getActualTypeArguments();
            // 由于这里是有一个泛型T,因此取第一个就是传进来的泛型的Type
            T result = new Gson().fromJson(data, types[0]);
            onSuccess(result, "" + data.getCode(), data.getMsg());
        } else {
            throw new ClassCastException();
        }
    }
}

在拿到请求结果以后通过回调的onNext()将返回结果交给回调来处理。在onNext()中解析完数据再调用onSuccess()等其他回调。

这么一来请求就可以这么写:

HttpUtils.get(url, param, object: HttpCallback<List<Chapters>> {
    onSuccess{}
    onError{}
})

小结:该方案使用回调的方式,通过回调这个匿名内部获取返回类型的Type,最终把解析后的结果回调出来。

方案二

由于这里使用到了Kt协程,Kt协程允许我们以同步的方式实现异步的效果,这似乎跟回调有点不搭,那如何像Retrofit一样直接返回结果呢?

由于没有了回调对象,此时面临的问题依然是返回结果的类型的传递。

Retrofit是在接口方法上配置的返回类型,在动态代理中通过调用method的getGenericReturnType()可以获取到方法返回类型的Type,进而可以获取到期望返回类型的Type。

// 获取到Call<List<Chapters>>
Type returnType = method.getGenericReturnType();
// 获取数组[List<Chapters>]
Type[] types = returnType.getActualTypeArguments();
// 获取List<Chapters>
Type returnType = types[0]

我们不能像Retrofit那样事先给方法配置好类型,那能否通过泛型方法传递呢?形如:

val result = HttpUtils.get<List<Chapters>>(url, param, header)

如果使用Java,答案是不行。然而Kotlin可以,这基于Kotlin提供的两个特性:

1,内联函数

内联函数会将被调用的函数体直接替换到函数调用的地方。

2,泛型reified关键字

reified关键字标记的泛型会被实化,一般配合内联函数使用。

首先了解一下reified关键字。

1,了解reified关键字

在Java中使用泛型的时候,无法通过泛型来得到Class,一般我们会将Class通过参数传过去,和方案一同样的问题。

比如在启动一个activity时,可以给Activity添加扩展函数:

fun <T : Activity> Activity.startActivity(clazz: Class<T>) {
    startActivity(Intent(this, clazz))
}

调用:

startActivity(Main2Activity::class.java)

kotlin提供的一个关键字reified(Reification 实化),它标记泛型使之成为实例化类型参数,使抽象的东西更加具体或真实。配合inline使用可以直接获取泛型的Class.

修改扩展函数:

inline fun <reified T : Activity> Activity.startActivity() {
    startActivity(Intent(this, T::class.java))
}

调用:

startActivity<Main2Activity>()

是不是很简(牛)单(逼),短短的一行代码足足省了好几个字母。

2,预研

那么如何在我们的包装层中运用Kotlin的这一特性呢?

首先做一个简单的测试:

// 定义
inline fun <reified T> request(url: String) {
    val clazz = T::class.java
    LogUtil.e(clazz.toString())
}

// 调用
request<List<String>>("www.baidu.com")

打印如下:

-->interface java.util.List

发现List是获取到了,但其中的String还是被擦除了,因此对于嵌套泛型无法完整获取其Type。What the ** ! 这该怎么办呢?

回想一下Gson是如何解析类似List<String>这种嵌套泛型呢,在Gson的注释中有如下代码:

// 通过空的匿名内部类获取List<String>的Type
Type listType = new TypeToken<List<String>>() {}.getType();

List<String> target = new LinkedList<String>();
target.add("blah");
Gson gson = new Gson();
// 对象转json
String json = gson.toJson(target, listType);
// json解析
List<String> target2 = gson.fromJson(json, listType);

它是通过空的匿名内部类来获取List<String>的Type,查看TypeToken的代码可知,它和方案一采用的同种方式获取的Type,只不过它封装了一个类来专门处理这个问题:

class TypeToken<T>{
    
    final Type type;

    protected TypeToken() {
        this.type = getSuperclassTypeParameter(getClass());
    }

    static Type getSuperclassTypeParameter(Class<?> subclass) {
        Type superclass = subclass.getGenericSuperclass();
        ...
        ParameterizedType parameterized = (ParameterizedType) superclass;
        return $Gson$Types.canonicalize(parameterized.getActualTypeArguments()[0]);
    }
    ...
}

那我们能否用同样的方式获取泛型T的Type呢?试一下:

inline fun <reified T> request(url: String) {
    val type = object : TypeToken<T>() {}.type
    LogUtil.e(type.toString())
}

打印结果:

-->java.util.List<? extends java.lang.String>

发现可以获取到其完整的Type,那就可以将其作为参数传递了。

3,实战

// get请求
suspend inline fun <reified T> get(
    url: String,
    param: HashMap<String, Any>? = null,
    headers: HashMap<String, String>? = null
): T {
    val returnType = object : TypeToken<T>() {}.type
    return get(url, param, headers, returnType)
}

// 包装get请求的请求参数,最后通过execRequest()统一发起请求
suspend fun <T> get(
    url: String,
    param: HashMap<String, Any>? = null,
    headers: HashMap<String, String>? = null,
    returnType: Type
): T {
    val urlBuilder = HttpUrl.parse(url)!!.newBuilder()
    param?.let {
        it.keys.forEach { key ->
            urlBuilder.addQueryParameter(key, it[key].toString())
        }
    }
    return execRequest(
        "GET",
        urlBuilder.build(),
        headers,
        null,returnType
    )
}

// todo post,put,delete请求等

// 统一请求方法
suspend fun <T> execRequest(
    method: String,
    httpUrl: HttpUrl,
    headers: HashMap<String, String>? = null,
    requestBody: RequestBody?,
    returnType: Type
): T {
    val request = Request.Builder().url(httpUrl).method(method, requestBody)
    headers?.keys?.forEach {
        request.addHeader(it, headers[it])
    }
    try {
        OkHttpUtils.mClient.newCall(request).execute().use { response ->
            val body = response.body()?.string()
            val jsonObject = JSONObject(body)
            val code = jsonObject.get("errorCode")
            when (code) {
                0 -> {
                    val data = jsonObject.get("data").toString()
                    return Gson().fromJson(data, returnType)
                }
                ...
                else -> {
                    throw MyException("业务异常:?code")
                }
            }
        }
    } catch (e: Throwable) {
        throw e
    }
}

以上只需要对OkHttp简单的封装即可很方便的发起网络请求:

viewModelScope.launch(Dispatchers.IO) { 
    val url = "https://wanandroid.com/wxarticle/chapters/json"
    // 请求网络返回数据
    val result = HttpUtils.get<List<Chapters>>(url)
}

其他的请求方式如post,put,delete等只需要根据其请求特点稍加处理最后统一调用的execRequest()方法即可。

小结:该方案不使用回调,通过泛型方法借助Kotlin的特性实现返回类型Type的获取,将解析结果直接返回。

状态及异常统一处理

以下不是本文的重点,只是探讨一下请求中状态及异常如何处理,如果有不足的地方或更好的方案请不吝赐教。

由于使用Kt协程,网络请求运行在协程IO中,因此使用的是OkHttp的同步请求,这就需要对网络请求进行try-catch捕获异常。

在ViewModel+LiveData的场景下,如果存在多个网络请求,就会存在一个问题:需要定义多个Start/Finish以及Error等状态的LiveData供UI层监听。一般这些状态可能做的是相同的操作:Start时启动Loading,Finish时关闭Loading,异常时给出异常提示。因此就需要对这些状态进行封装。

相关的封装方案也有很多。这里简单给出一个方案,仅供参考

因为Start/Finish/Error等状态一般是统一处理,那就把他们封装到一个sealed class中。

sealed class LoadState {
    /**
     * 开始
     */
    class Start(var tip: String = "正在加载中...") : LoadState()

    /**
     * 异常
     */
    class Error(val msg: String) : LoadState()

    /**
     * 结束
     */
    object Finish : LoadState
}

在BaseViewModel中定义LoadState的LiveData供View层监听:

open class BaseViewModel() : ViewModel() {

    // 加载状态
    val loadState = MutableLiveData<LoadState>()
    ...
}

UI中:

viewModel.loadState.observe(this) {
    when (it) {
        is LoadState.Start -> {
            // todo 开始加载
        }
        is LoadState.Error -> {
            // todo 加载失败
        }
        is LoadState.Finish -> {
            // todo 加载完成
        }
    }
}

有了观察者和被观察者,那何时分发数据呢?

还是在BaseViewModel中将网络请求通过高阶函数的方式在协程中执行,如下:

open class BaseViewModel() : ViewModel() {

    // 加载状态
    val loadState = MutableLiveData<LoadState>()
    
    // 通过该方法发起网络请求
    private fun launch(block: suspend CoroutineScope.() -> Unit) {
        viewModelScope.launch() {
            try {
                loadState.value = LoadState.Start()
                withContext(Dispatchers.IO) {
                    // 执行网络请求代码块
                    block.invoke(this)
                }
            } catch (e: Throwable) {
                // handle error
                val error = ExceptionUtils.parseException(e)
                loadState.value = LoadState.Error(error)
            } finally {
                loadState.value = LoadState.Finish
            }
        }
    }
}

在ViewModel中:

val chapters = MutableLiveData<List<Chapters>>()

fun request(){
    launch {
        val url = "https://wanandroid.com/wxarticle/chapters/json"
        // 请求网络返回数据
        val result = HttpUtils.get<List<Chapters>>(url)
        chapters.postValue(result)
    }
}

这么一来在ViewModel中只需要通过launch函数发起网络请求就可以让请求在IO线程中执行,并且自动分发Start/Finish/Error等状态。

在UI中只需要监听LoadState以及网络请求返回结果的LiveData即可。

至此在Kotlin+ViewModel+LiveData环境下简单的网络请求封装已经完成,但还存在一些问题:

问题1:

有些请求是在后台静默执行,不需要处理开始结束异常的状态。

此时可以从BaseViewModel中解决,同launch函数一样,添加launchSlient函数,其中控制是否分发LoadState状态。

问题2:

同时发起多个请求,期间都需要显示Loading,但是某一个先完成了,就回调了LoadState.Finish,导致其他请求还在进行中但Loading已经关闭了。

可以在BaseViewModel中通过原子操作AtomicInteger,记录当前请求中的数量,可以在其数量为0时分发LoadState.Finish

问题3:

对于一些业务异常可能需要特殊处理,不能在统一的方式中处理。

此时需要在包装层处理,可将统一异常处理作为兜底策略,对于特殊的业务异常,捕获后不向外抛出,通过高阶函数方式处理:

launch {
    val url = "https://wanandroid.com/wxarticle/chapters/json"
    // 请求网络返回数据
    val result = HttpUtils.get<List<Chapters>>(url){ error ->
        // todo 处理异常
    }
}

总结

本文重点探讨了在kotlin+协程+viewmodel+livedata环境下通过对OkHttp的包装让网络请求更加简单的方案。

当然这并不是说可以完全抛弃Retrofit,Retrofit是一个大而全的网络请求封装,能够满足各种需求。

文中只用了几十行代码实现了get请求,详细代码及其他封装可参考本人的开源项目风云天气https://github.com/wdsqjq/FengYunWeather

以上方案适用于简单的网络请求场景,对于特殊的需求还需要自行扩展

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容