Retrofit+协程,网络请求封装实战

Retrofi作为Android开发中使用比较广泛的网络框架自发布之日起就受到了大家的广泛好评,而随着2.6版本增加了对Kotlin协程的支持后,更加优雅的使用方式让大家眼前一亮,代码竟然还能这么写!那么协程到底是什么呢?为何能让Retrofit焕然一新呢?

协程简介

1. 简介

相信使用过Go、Python等语言的同学对于协程这一概念早已有所耳闻,协程不是某个语言所特有的特性而是一种编程思想,一种非抢占式的任务调度模式,程序可以主动挂起或者恢复执行。它的设计初衷是为了解决并发问题,让协作式多任务实现起来更加方便,Kotlin官方将其称为一种轻量级线程。

协程和线程听起来貌似区别不大,但是他们是两个完全不同的概念。简单来讲,我们所有的代码都是运行在线程中的,协程同样也是运行在线程中的,可以是单线程也可以是多线程,而线程则是运行在进程中的,所以他们有着本质的不同。

由于本文的重点在于介绍Retrofit+协程在实际应用过程中如何进行二次封装,所以对于协程仅做简单介绍,具体内容还望大家自行搜索,文章最后也提供了一些官方文档和优秀的文章供大家参考。

2. 为什么使用协程

使用协程有很多优点,比如相比较线程占用资源更少,执行更加高效等,但是在Android网络开发中,使用协程配合Retrofit所带来的最直观的好处就是可以简化异步执行的代码,尤其是在需要请求多个接口的场景下时可以有效避免“回调地狱”。下面以一个需要串行请求多个接口的场景为例,看一下使用协程前后的变化。

  • 传统使用Retrofit+RxJava的方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private fun getUserInfo() {
apiService.getUserInfo()
.subscribe(object : Observer<Response<UserResponse?>?> {

fun onNext(response: Response<UserResponse?>?) {
apiService.getConfiguration()
.subscribe(object : Observer<Response<ConfigResponse?>?> {

fun onNext(response: Response<ConfigResponse?>?) {
//渲染数据
}

})
}
})
}
  • 使用Retrofit+协程的方式:
1
2
3
4
5
6
7
8
9
10
11
private fun getUserInfo() {
launch {
val user = withContext(Dispatchers.IO) {
apiService.getUserInfo()
}
val config = withContext(Dispatchers.IO) {
apiService.getConfiguration()
}
//渲染数据
}
}

从上述示例中可以看出,在使用协程前对于异步任务我们需要通过回调来获取结果,当有多个异步任务需要串行时就会出现“回调地狱”,非常不利于阅读和维护。而使用协程后则可以通过同步的代码去完成异步的网络请求,整个过程简洁清晰,好处不言而喻。

3. 为什么需要封装

既然上述代码已经非常简洁清晰了那么我们还需要做什么封装呢?细心地你可能已经发现了上述两个示例其实都没有处理异常的情况,而现实是在网络请求的过程中会遇到各种异常,比如UnknownHostException、CertificateException、JsonParseException等,除了这些请求过程中的异常还会有业务上的异常,比如用户token过期、用户名密码错误等。所以一个完整的网络请求要复杂的多,下面给出一个增加了异常处理逻辑的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainActivity : BaseActivity(), CoroutineScope by MainScope() {

fun getUserInfo() {
launch {
try {
val data = withContext(Dispatchers.IO) {
apiService.getUserInfo()
}
if (data.isSuccess) {
//渲染数据
} else {
//处理服务端错误
}
} catch (e: Exception) {
//处理请求异常
}
}
}

}

从这个例子中可以明显发现一个问题那就是整个流程过于繁琐,如果每个请求都需要这么写相信对于开发人员来讲无异于噩梦。所以这里必须要进行封装,而封装的关键便在于封装对异常的处理。因为通常在没有特殊业务要求的情况下对于失败我们一般都是使用相同的处理逻辑,比如展示一个错误状态的UI或者Toast提示用户等。

方向确定了下面就让我们开始动手进行封装,这里主要介绍两种方式,一种是利用Kotlin的DSL,另一种是借助CoroutineExceptionHandler。

方案一,利用DSL

1. 使用

首先让我们来看一下封装后的效果,然后再看一下具体是如何实现的:

1
2
3
4
5
6
7
8
9
10
11
fun getUserInfo() {
requestApi<UserResponse> {
response = apiService.getUserInfo()
onSuccess {
//处理成功相关的逻辑
}
onError {
//处理异常相关的逻辑,如果无特殊需求可以不调用onError方法
}
}
}

2. 实现

这里主要还是利用了Kotlin高阶函数和扩展方法的特性,具体实现如下:

  1. 定义一个DSL的Api
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class HttpRequestDsl<T : IResponse> {

lateinit var response: T

internal var onSuccess: ((T) -> Unit)? = null

internal var onError: ((Exception) -> Unit)? = null

fun onSuccess(onSuccess: ((T) -> Unit)?) {
this.onSuccess = onSuccess
}

fun onError(onError: ((Exception) -> Unit)?) {
this.onError = onError
}

}
  1. 定义扩展方法,可以根据实际需要定义CoroutineScope或者BaseActivity等类的扩展方法,这里因为我们是直接在Activity中进行网络请求的,所以我们给BaseActivity增加了一个扩展方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fun <T : IResponse> BaseActivity.requestApi(dsl: suspend HttpRequestDsl<T>.() -> Unit) {
launch {
val httpRequestDsl = HttpRequestDsl<T>()
try {
withContext(Dispatchers.IO) {
httpRequestDsl.dsl()
}
val response = httpRequestDsl.response
if (response.isSuccess) {
httpRequestDsl.onSuccess?.invoke(response)
} else {
httpRequestDsl.onError?.invoke(ApiErrorException(response))
}
} catch (e: Exception) {
if (httpRequestDsl.onError == null) {
//BaseActivity中处理异常的通用方法
handleCommonException(e)
} else {
httpRequestDsl.onError!!.invoke(e)
}
}
}
}

3. 小结

可以看到经过简单的两步我们便可以把一开始非常繁琐的网络请求简化为最短三行代码,有没有感觉非常酷,这就是Kotlin魅力之一吧。

经过这一番折腾我们已经把网络请求变得简单易用了,但是细心的你可能又发现了一个问题,那就是回调代码又出现了,虽然因为高阶函数的存在写法变得简单了,虽然回调我们已经熟悉的不能再熟悉了,但还是感觉有点不爽,因为好不容易用上的协程的同步特性封装之后又体验不到了,而且对于多接口同时请求的场景这种封装方案会存在很多不足。

想想有没有什么办法可以让我们依然使用同步的方式完成请求,同时又不需要书写这么复杂的异常处理逻辑呢?这时候我们想到了CoroutineExceptionHandler。

方案二,借助CoroutineExceptionHandler

1. 使用

首先还是让我们来看一下使用方案二封装之后的效果,可以看到与原始实现最大的差异就是这里没有任何异常处理相关的逻辑,难道是真的没有处理异常吗?答案当然是否定的。

1
2
3
4
5
6
7
8
9
10
class MainActivity : BaseActivity() {
fun getUserInfo() {
mainScope.launch {
val data = withContext(Dispatchers.IO) {
apiService.getUserInfo()
}
//渲染数据
}
}
}

2. 实现

上面讲到方案一的特点是他是一个异步的代码,不如同步代码清晰易懂。那么想要在保持同步代码的基础上又能兼顾异常的处理需要解决两个问题:一是如何干掉try-catch,二是如何干掉业务异常的处理逻辑,但同时又必须保证异常都可以被正确处理。

这时候我们想到了使用CoroutineExceptionHandler全局捕获异常,这里关于协程异常的传播就不做过多介绍了,我们只需简单理解为协程子作用域产生的异常会一级级向上传播最后由CoroutineExceptionHandler进行处理便可(协程的异常传播比较复杂,这里表述并不完整)。具体实现如下:

  1. 创建一个CoroutineExceptionHandler的实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CoroutineExceptionHandlerImpl : CoroutineExceptionHandler {

override val key = CoroutineExceptionHandler

override fun handleException(context: CoroutineContext, exception: Throwable) {
when (exception) {
is ApiErrorException -> {
//处理业务错误
}
is JsonParseException -> {
//数据解析异常
}
is CertificateException, is SSLHandshakeException -> {
//证书异常
}
...
else -> {
}
}
}

}
  1. 在基类中不再通过委托的方式实现CoroutineScope,而是定义自己的mainScope,同时为其附加上刚刚定义的CoroutineExceptionHandlerImpl
1
2
3
4
5
abstract class BaseActivity : AppCompatActivity() {

protected val mainScope = MainScope() + CoroutineExceptionHandlerImpl()

}
  1. 这样我们就可以省略掉try-catch了,因为未被捕获的异常都会传播到CoroutineExceptionHandlerImpl中,我们可以在其中处理异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainActivity : BaseActivity() {
fun getUserInfo() {
mainScope.launch {
val data = withContext(Dispatchers.IO) {
apiService.getUserInfo()
}
if (data.isSuccess) {
//渲染数据
} else {
//处理服务端错误
}
}
}
}

经过这样一番操作后我们首先解决了try-catch的问题,然后再来想办法解决掉处理业务层面错误的相关代码。既然协程中的异常都可以被CoroutineExceptionHandler捕获,那么如果我们把业务错误也当做一种异常抛出去不就也可以在CoroutineExceptionHandlerImpl中一起处理了吗?感觉有戏。

现在的问题是如果我们在最终获取数据之后才抛出业务异常的话,那么针对业务的if-else判断依然要写,复杂度没有任何的变化,所以最好是在拿到数据之后就能先做判断才是比较好的方式。

幸运的是Retrofit使用的责任链模式让我们可以很容易就完成这件事情,只要在这条链上的合适位置增加一个钩子就可以完成我们想要的判断。经过一番思考最终我们选择在反序列化数据时执行这一检查,也就是在初始化Retrofit时传一个自定义的ConverterFactory和Converter,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ResponseConverter<T> : Converter<ResponseBody, T> {

override fun convert(value: ResponseBody): T? {
val result = adapter.fromJson(value.string())
if (result is IResponse) {
if (!result.isSuccess) {
//发生业务上的错误时,将结果包裹在ApiErrorException中作为异常抛出
throw ApiErrorException(result)
}
}
return result
}

}

这样只要在CoroutineExceptionHandler#handleException()中区分一下异常类型便可对不同的异常做差异化的处理,就像在「使用」中介绍的那样。

最后还有一个问题就是,如果我们想要针对某一个接口的异常做一些特殊处理比如在登录时如果用户输入的用户名或密码不对,那么我们需要根据后端返回的错误code对应清除用户名或密码时怎么办?很简单依然是使用try-catch,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MainActivity : BaseActivity() {
fun getUserInfo() {
mainScope.launch {
try {
val data = withContext(Dispatchers.IO) {
apiService.getUserInfo()
}
//渲染数据
} catch (exception: ApiErrorException) {
(exception.response as? UserResponse)?.let {
//根据业务需求进行针对性处理
}
}
}
}
}

3. 小结

方案二相较于方案一虽然代码量有所增多,但增多的主要是模板代码,可以利用Live Templates来解决,想必大家都很熟悉。与方案一的异步回调相比较而言,同步的书写方式更加符合自然语义,阅读和使用起来也都更加方便。

总结

上面便是在使用Retrofit+协程的过程中两种封装方式的简单介绍,不同的方案有着各自的优缺点也有各自适合的业务场景,如果脱离实际的技术路线和业务需求强行去说某种方案更好是没有意义的。

比如方案一因为采用回调,所以在处理复杂状态变化时更加方便,想要增加对Empty、Loading等状态的处理时再多加几个回调便可,而方案二要想在保持同步代码的基础上去封装各种状态就会比较困难。

但如果项目比较简单使用方案二完全可以满足需求的话,方案二可以让整体代码更加简洁,可维护性也会比较高,配合上Jetpack组件库也可以处理复杂业务,完成高效开发。

最后想说的是协程的能力远不止于此,探索之路道阻且长,希望后续能够持续发现Kotlin的魅力所在,也希望还没上车的小伙伴要抓点紧了。

扩展链接:

  1. Android 上的 Kotlin 协程
  2. 协程中文文档
  3. 破解Kotlin协程系列文章
  4. 协程为什么被称为『轻量级线程』