Android中用Kotlin Coroutine(协程)和Retrofit进行网络请求和取消请求

7,319 阅读7分钟

Kotlin Coroutine(协程)系列:
1. Kotlin Coroutine(协程) 简介
2. Kotlin Coroutine(协程) 基本知识
3. Android中用Kotlin Coroutine(协程)和Retrofit进行网络请求和取消请求

前面两篇文章介绍了协程的一些基本概念和基本知识,这篇则介绍在Android中如何使用协程配合Retrofit发起网络请求,同时介绍在使用协程时如何优雅的取消已经发起的网络请求。

此篇文章的Demo地址:https://github.com/huyongli/AndroidKotlinCoroutine

创建CoroutineScope

在前面的文章中我写到CoroutineScope.launch方法是一个很常用的协程构建器。因此使用协程必须先得创建一个CoroutineScope对象,代码如下:

CoroutineScope(Dispatchers.Main + Job())

上面的代码创建了一个CoroutineScope对象,为其协程指定了在主线程中执行,同时分配了一个Job

在demo中我使用的是MVP模式写的,所以我将CoroutineScope的创建放到了BasePresenter中,代码如下:

interface MvpView

interface MvpPresenter<V: MvpView> {

    @UiThread
    fun attachView(view: V)

    @UiThread
    fun detachView()
}

open class BasePresenter<V: MvpView> : MvpPresenter<V> {
    lateinit var view: V
    val presenterScope: CoroutineScope by lazy {
        CoroutineScope(Dispatchers.Main + Job())
    }

    override fun attachView(view: V) {
        this.view = view
    }

    override fun detachView() {
        presenterScope.cancel()
    }
}

使用CoroutineScope.cancel()取消协程

大家应该可以看到上面BasePresenter.detachView中调用了presenterScope.cancel(),那这个方法有什么作用呢,作用就是取消掉presenterScope创建的所有协程和其子协程。

前面的文章我也介绍过使用launch创建协程时会返回一个Job对象,通过Job对象的cancel方法也可以取消该任务对应的协程,那我这里为什么不使用这种方式呢?

很明显,如果使用Job.cancel()方式取消协程,那我创建每个协程的时候都必须保存返回的Job对象,然后再去取消,显然要更复杂点,而使用CoroutineScope.cancel()则可以一次性取消该协程上下文创建的所有协程和子协程,该代码也可以很方便的提取到基类中,这样后面在写业务代码时也就不用关心协程与View的生命周期的问题。

其实大家看源码的话也可以发现CoroutineScope.cancel()最终使用的也是Job.cancel()取消协程

扩展Retrofit.Call适配协程

interface ApiService {
    @GET("data/iOS/2/1")
    fun getIOSGank(): Call<GankResult>

    @GET("data/Android/2/1")
    fun getAndroidGank(): Call<GankResult>
}

class ApiSource {
    companion object {
        @JvmField
        val instance = Retrofit.Builder()
            .baseUrl("http://gank.io/api/")
            .addConverterFactory(GsonConverterFactory.create())
            .build().create(ApiService::class.java)
    }
}

大家可以看到上面的api接口定义应该很熟悉,我们可以通过下面的代码发起异步网络请求

ApiSource.instance.getAndroidGank().enqueue(object : Callback<T> {
    override fun onFailure(call: Call<T>, t: Throwable) {
        
    }

    override fun onResponse(call: Call<T>, response: Response<T>) {
        
    }
})

前面的文章介绍过协程可以让异步代码像写同步代码那样方便,那上面这段异步代码能不能使用协程改造成类似写同步代码块那样呢?很显然是可以的,具体改造代码如下:

//扩展Retrofit.Call类,为其扩展一个await方法,并标识为挂起函数
suspend fun <T> Call<T>.await(): T {
    return suspendCoroutine {
        enqueue(object : Callback<T> {
            override fun onFailure(call: Call<T>, t: Throwable) {
                //请求失败,抛出异常,手动结束当前协程
                it.resumeWithException(t)
            }

            override fun onResponse(call: Call<T>, response: Response<T>) {
                if(response.isSuccessful) {
                   //请求成功,将请求结果拿到并手动恢复所在协程
                   it.resume(response.body()!!)
                } else{
                   //请求状态异常,抛出异常,手动结束当前协程
                   it.resumeWithException(Throwable(response.toString()))
                }
            }
        })
    }
}

上面的代码扩展了一个挂起函数await,执行该方法时,会执行Retrofit.Call的异步请求同时在协程中挂起该函数,直到异步请求成功或者出错再重新恢复所在协程。

suspendCoroutine

全局函数,此函数可以获取当前方法所在协程上下文,并将当前协程挂起,直到某个时机再重新恢复协程执行,但是这个时机其实是由开发者自己控制的,就像上面代码中的it.resumeit.resumeWithException

发起请求,写法一

//使用CoroutineScope.launch创建一个协程,此协程在主线程中执行
presenterScope.launch {
    val time = System.currentTimeMillis()
    view.showLoadingView()
    try {
        val ganks = queryGanks()
        view.showLoadingSuccessView(ganks)
    } catch (e: Throwable) {
        view.showLoadingErrorView()
    } finally {
        Log.d(TAG, "耗时:${System.currentTimeMillis() - time}")
    }
}

suspend fun queryGanks(): List<Gank> {
    //此方法执行线程和调用者保持一致,因此也是在主线程中执行
    return try {
        //先查询Android列表,同时当前协程执行流程挂起在此处
        val androidResult = ApiSource.instance.getAndroidGank().await()
        
        //Android列表查询完成之后恢复当前协程,接着查询IOS列表,同时将当前协程执行流程挂起在此处
        val iosResult = ApiSource.instance.getIOSGank().await()

        //Android列表和IOS列表都查询结束后,恢复协程,将两者结果合并,查询结束
        val result = mutableListOf<Gank>().apply {
            addAll(iosResult.results)
            addAll(androidResult.results)
        }
        result
    } catch (e: Throwable) {
        //处理协程中的异常,否则程序会崩掉
        e.printStackTrace()
        throw e
    }
}

从上面的代码大家可以发现,协程中对异常的处理使用的是try-catch的方式,初学,我也暂时只想到了这种方式。所以在使用协程时,最好在业务的适当地方使用try-catch捕获异常,否则一旦协程执行出现异常,程序就崩掉了。

另外上面的代码的写法还有一个问题,因为挂起函数执行时会挂起当前协程,所以上述两个请求是依次顺序执行,因此上面的queryGanks()方法其实是耗费了两次网络请求的时间,因为请求Android列表和请求ios列表两个请求不是并行的,所以这种写法肯定不是最优解。

发起请求,写法二

下面我们再换另外一种写法。

suspend fun queryGanks(): List<Gank> {
    /**
     * 此方法执行线程和调用者保持一致,因此也在主线程中执行
     * 因为网络请求本身是异步请求,同时async必须在协程上下文中执行,所以此方法实现中采用withContext切换执行线程到主线程,获取协程上下文对象
     */
    return withContext(Dispatchers.Main) {
        try {
            //在当前协程中创建一个新的协程发起Android列表请求,但是不会挂起当前协程
            val androidDeferred = async {
                val androidResult = ApiSource.instance.getAndroidGank().await()
                androidResult
            }

            //发起Android列表请求后,立刻又在当前协程中创建了另外一个子协程发起ios列表请求,也不会挂起当前协程
            val iosDeferred = async {
                val iosResult = ApiSource.instance.getIOSGank().await()
                iosResult
            }

            val androidResult = androidDeferred.await().results
            val iosResult = iosDeferred.await().results

            //两个列表请求并行执行,等待两个请求结束之后,将请求结果进行合并
            //此时当前方法的执行时间实际上两个请求中耗时时间最长的那个,而不是两个请求所耗时间的总和,因此此写法优于上面一种写法
            val result = mutableListOf<Gank>().apply {
                addAll(iosResult)
                addAll(androidResult)
            }
            result
        } catch (e: Throwable) {
            e.printStackTrace()
            throw e
        }
    }
}

这种写法与前一种写法的区别是采用async构建器创建了两个子协程分别去请求Android列表和IOS列表,同时因为async构建器执行的时候不会挂起当前协程,所以两个请求是并行执行的,因此效率较上一个写法要高很多。

发起请求,写法三

第三个写法就是在RetorfitCallAdapter上做文章,通过自定义实现CallAdapterFactory,将api定义时的结果Call直接转换成Deferred,这样就可以同时发起Android列表请求和IOS列表请求,然后通过Deferred.await获取请求结果,这种写法是写法一写法二的结合。

这种写法JakeWharton大神早已为我们实现了,地址在这github.com/JakeWharton…

这里我就不说这种方案的具体实现了,感兴趣的同学可以去看其源码。

写法三的具体代码如下:

val instance = Retrofit.Builder()
        .baseUrl("http://gank.io/api/")
        .addCallAdapterFactory(CoroutineCallAdapterFactory())
        .addConverterFactory(GsonConverterFactory.create())
        .build().create(CallAdapterApiService::class.java)
        
suspend fun queryGanks(): List<Gank> {
    return withContext(Dispatchers.Main) {
        try {
            val androidDeferred = ApiSource.callAdapterInstance.getAndroidGank()

            val iosDeferred = ApiSource.callAdapterInstance.getIOSGank()

            val androidResult = androidDeferred.await().results

            val iosResult = iosDeferred.await().results

            val result = mutableListOf<Gank>().apply {
                addAll(iosResult)
                addAll(androidResult)
            }
            result
        } catch (e: Throwable) {
            e.printStackTrace()
            throw e
        }
    }
}

上面的第三种写法看起来更简洁,也是并行请求,耗时为请求时间最长的那个请求的时间,和第二种差不多。

具体实现demo的地址见文章开头,有兴趣的可以看看。



下面是我的个人公众号,欢迎关注交流