前面的两篇文章介绍了协程的一些基本概念和常用知识,这篇文章则介绍在Android中如何使用Retrofit配合协程发起网络请求,同时介绍在使用协程时如何优雅的取消已经发起的网络请求。
此篇文章的Demo地址:
https://github.com/huyongli/AndroidKotlinCoroutine
创建CoroutineScope
前面的文章中我们已经介绍过CoroutineScope,它为协程定义了一个范围也代表了协程的上下文环境,事实上kotlin中协程都必须在一个指定的协程上下文环境中才能执行,所以执行协程我们先得创建一个 CoroutineScope对象,创建很简单,直接调用其构造函数就可以了,代码如下:
CoroutineScope(Dispatchers.Main + Job())
上面的代码创建了一个CoroutineScope对象,该对象在创建的时候通过 Dispatchers.Main 为该上下文环境中的所有协程默认指定在主线程中执行,同时为该对象分配了一个 Job
在demo中我使用的是MVP模式写的,所以我将CoroutineScope的创建放到了 BasePresenter中,代码如下:
interface MvpViewinterface 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()方式取消协程,那我每次创建协程的时候都必须保存launch方法返回的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.resume和it.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列表两个请求不是并行的,所以这种写法肯定不是最优解。
发起请求,写法二
下面我们再换另外一种写法,前面的文章中介绍过async协程构建器,通过它可以创建一个协程,同时可以获取协程的执行结果,重要是该方法不会挂起当前协程,所以我们可以使用async来改造上面的请求过程,代码如下:
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构建器执行的时候不会挂起当前协程,所以两个请求是并行执行的,因此效率较上一个写法要高很多。
发起请求,写法三
第三个写法就是在Retorfit的CallAdapter上做文章,通过自定义实现CallAdapterFactory,将api定义时的结果Call直接转换成Deferred,这样就可以同时发起Android列表请求和IOS列表请求,然后通过Deferred.await获取请求结果,这种写法是写法一写法二的结合。
这种写法JakeWharton大神早已为我们实现了,地址:
https://github.com/JakeWharton/retrofit2-kotlin-coroutines-adapter
这里我就不说这种方案的具体实现了,感兴趣的同学可以去看其源码。
写法三的具体代码如下:
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 } }}
上面的第三种写法看起来更简洁,也是并行请求,耗时为请求时间最长的那个请求的时间,和第二种差不多,但是代码写法上会更简洁,所以建议大家采用这种写法实现网络请求。