教你如何使⽤协程(四)协程+Kotlin+Retrofit实现⽹络请求接触新概念,最好的办法就是先整体看个⼤概,再回过头来细细品味
需求确认
在开始讲解本⽂之前,我们需要先确认⼏件事⼉:
1. 你⽤过线程对吧?
2. 你写过回调对吧?
3. 你⽤过 RxJava 类似的框架吗?
看下你的答案:
1. 如果上⾯的问题的回答都是 “Yes”,那么太好了,这篇⽂章⾮常适合你,因为你已经意识到回调有多么可怕,并且到了解决⽅
案;
2. 如果前两个是 “Yes”,没问题,⾄少你已经开始⽤回调了,你是协程潜在的⽤户;
3. 如果只有第⼀个是 “Yes”,那么,可能你刚刚开始学习线程,那你还是先打好基础再来吧~
⼀个常规例⼦
我们通过 Retrofit 发送⼀个⽹络请求,其中接⼝如下:
interface GitHubServiceApi {
@GET("users/{login}")
fun getUser(@Path("login") login: String): Call<User>
}
data class User(val id: String,val name: String,val url: String)
Retrofit 初始化如下:
val gitHubServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.
baseUrl("api.github")
.ate())
.build()
}
那么我们请求⽹络时:
override fun onFailure(call: Call<User>, t: Throwable){
handler.post{showError(t)}
}
override fun onResponse(call: Call<User>, response: Response<User>){
handler.post{ response.body()?.let(::showUser)?:showError(NullPointerException())}
}
})
请求结果回来之后,我们切换线程到 UI 线程来展⽰结果。这类代码⼤量存在于我们的逻辑当中,它有什么问题呢?
1. 通过 Lambda 表达式,我们让线程切换变得不是那么明显,但它仍然存在,⼀旦开发者出现遗漏,这⾥就会出现问题
2. 回调嵌套了两层,看上去倒也没什么,但真实的开发环境中逻辑⼀定⽐这个复杂的多,例如登录失败的重试
3. 重复或者分散的异常处理逻辑,在请求失败时我们调⽤了⼀次 showError,在数据读取失败时我们⼜调⽤了⼀次,真实的开发环境中
可能会有更多的重复
Kotlin 本⾝的语法已经让这段代码看上去好很多了,如果⽤ Java 写的话,你的直觉都会告诉你:你在写 Bug。
如果你不是 Android 开发者,那么你可能不知道 handler 是什么东西,没关系,你可以替换为 SwingUtilities.invokeLater{ … } (Java Swing),或者 setTimeout({ … }, 0) (Js) 等等。
改造成协程
你当然可以改造成 RxJava 的风格,但 RxJava ⽐协程抽象多了,因为除⾮你熟练使⽤那些 operator,不然你根本不知道它在⼲嘛(试想⼀下 retryWhen)。协程就不⼀样了,毕竟编译器加持,它可以很简洁的表达出代码的逻辑,不要想它背后的实现逻辑,它的运⾏结果就是你直觉告诉你的那样。
对于 Retrofit,改造成协程的写法,有两种,分别是通过 CallAdapter 和 suspend 函数。
4.1 CallAdapter 的⽅式
我们先来看看 CallAdapter 的⽅式,这个⽅式的本质是让接⼝的⽅法返回⼀个协程的 Job:
interface GitHubServiceApi {
@GET("users/{login}")
fun getUser(@Path("login") login: String): Deferred<User>
}
注意 Deferred 是 Job 的⼦接⼝。
那么我们需要为 Retrofit 添加对 Deferred 的⽀持,这需要⽤到开源库:
implementation 'fit:retrofit2-kotlin-coroutines-adapter:0.9.2'
构造 Retrofit 实例时添加:
val gitHubServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.baseUrl("api.github")
.ate())
/
/添加对 Deferred 的⽀持
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
}
那么这时候我们发起请求就可以这么写了:
GlobalScope.launch(Dispatchers.Main){
try{
User("bennyhuo").await())
}catch(e: Exception){
showError(e)
}
}
说明: Dispatchers.Main 在不同的平台上的实现不同,如果在 Android 上为 HandlerDispatcher,在 Java Swing 上为
SwingDispatcher 等等。
⾸先我们通过 launch 启动了⼀个协程,这类似于我们启动⼀个线程,launch 的参数有三个,依次为协程上下⽂、协程启动模式、协程体:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,// 上下⽂
start: CoroutineStart = CoroutineStart.DEFAULT,// 启动模式
block:suspend CoroutineScope.()-> Unit // 协程体
): Job
启动模式不是⼀个很复杂的概念,不过我们暂且不管,默认直接允许调度执⾏。
上下⽂可以有很多作⽤,包括携带参数,拦截协程执⾏等等,多数情况下我们不需要⾃⼰去实现上下⽂,只需要使⽤现成的就好。上下⽂有⼀个重要的作⽤就是线程切换,Dispatchers.Main 就是⼀个官⽅提供的上下⽂,它可以确保 launch 启动的协程体运⾏在 UI 线程当中(除⾮你⾃⼰在 launch 的协程体内部进⾏线程切换、或者启动运⾏在其他有线程切换能⼒的上下⽂的协程)。
换句话说,在例⼦当中整个 launch 内部你看到的代码都是运⾏在 UI 线程的,尽管 getUser 在执⾏的时候确实切换了线程,但返回结果的时候会再次切回来。这看上去有些费解,因为直觉告诉我们,getUser 返回了⼀个 Deferred 类型,它的 await ⽅法会返回⼀个 User 对象,意味着 await 需要等待请求结果返回才可以继续执⾏,那么 await 不会阻塞 UI 线程吗?
答案是:不会。当然不会,不然那 Deferred 与 Future ⼜有什么区别呢?这⾥ await 就很可疑了,因为它实际上是⼀个 suspend 函数,这个函数只能在协程体或者其他 suspend 函数内部被调⽤,它就像是回调的语法糖⼀样,它通过⼀个叫 Continuation 的接⼝的实例来返回结果:
@SinceKotlin("1.3")
public interface Continuation<in T>{
android retrofit
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
1.3 的源码其实并不是很直接,尽管我们可以再看下 Result 的源码,但我不想这么做。更容易理解的是之前版本的源码:
@SinceKotlin("1.1")
public interface Continuation<in T>{
public val context: CoroutineContext
public fun resume(value: T)
public fun resumeWithException(exception: Throwable)
}
相信⼤家⼀下就能明⽩,这其实就是个回调嘛。如果还不明⽩,那就对⽐下 Retrofit 的 Callback:
public interface Callback<T>{
void onResponse(Call<T> call, Response<T> response);
void onFailure(Call<T> call, Throwable t);
}
有结果正常返回的时候,Continuation 调⽤ resume 返回结果,否则调⽤ resumeWithException 来抛出异常,简直与 Callback ⼀模⼀样。
所以这时候你应该明⽩,这段代码的执⾏流程本质上是⼀个异步回调:
GlobalScope.launch(Dispatchers.Main){
try{
//showUser 在 await 的 Continuation 的回调函数调⽤后执⾏
User("bennyhuo").await())
}catch(e: Exception){
showError(e)
}
}
⽽代码之所以可以看起来是同步的,那就是编译器的⿊魔法了,你当然也可以叫它“语法糖”。
这时候也许⼤家还是有问题:我并没有看到 Continuation 啊,没错,这正是我们前⾯说的编译器⿊魔法了,在 Java 虚拟机上,await 这个⽅法的签名其实并不像我们看到的那样:
public suspend fun await(): T
它真实的签名其实是:
kotlinx/coroutines/Deferred.await(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
即接收⼀个 Continuation 实例,返回 Object 的这么个函数,所以前⾯的代码我们可以⼤致理解为:
//注意以下不是正确的代码,仅供⼤家理解协程使⽤
GlobalScope.launch(Dispatchers.Main){
override fun resume(value: User){
showUser(value)
}
override fun resumeWithException(exception: Throwable){
showError(exception)
}
})
}
⽽在 await 当中,⼤致就是:
//注意以下并不是真实的实现,仅供⼤家理解协程使⽤
fun await(continuation: Continuation<User>): Any {
...// 切到⾮ UI 线程中执⾏,等待结果返回
try{
val user =...
handler.post{ sume(user)}
}catch(e: Exception){
handler.post{ sumeWithException(e)}
}
}
这样的回调⼤家⼀看就能明⽩。讲了这么多,请⼤家记住⼀点:从执⾏机制上来讲,协程跟回调没有什么本质的区别。
suspend 函数的⽅式
suspend 函数是 Kotlin 编译器对协程⽀持的唯⼀的⿊魔法(表⾯上的,还有其他的我们后⾯讲原理的时候再说)了,我们前⾯已经通过Deferred 的 await ⽅法对它有了个⼤概的了解,我们再来看看 Retrofit 当中它还可以怎么⽤。
Retrofit 当前的 release 版本是 2.5.0,还不⽀持 suspend 函数。因此想要尝试下⾯的代码,需要最新的 Retrofit 源码的⽀持;当然,也许你看到这篇⽂章的时候,Retrofit 的新版本已经⽀持这⼀项特性了呢。
⾸先我们修改接⼝⽅法:
@GET("users/{login}")
suspend fun getUser(@Path("login") login: String): User
这种情况 Retrofit 会根据接⼝⽅法的声明来构造 Continuation,并且在内部封装了 Call 的异步请求(使⽤ enqueue),进⽽得到 User 实例,具体原理后⾯我们有机会再介绍。使⽤⽅法如下:
GlobalScope.launch{
try{
User("bennyhuo"))
}catch(e: Exception){
showError(e)
}
}
它的执⾏流程与 Deferred.await 类似,我们就不再详细分析了。
协程到底是什么
好,坚持读到这⾥的朋友们,你们⼀定是异步代码的“受害者”,你们肯定遇到过“回调地狱”,它让你的代码可读性急剧降低;也写过⼤量复杂的异步逻辑处理、异常处理,这让你的代码重复逻辑增加;因为回调的存在,还得经常处理线程切换,这似乎并不是⼀件难事,但随着代码体量的增加,它会让你抓狂,线上上报的异常因线程使⽤不当导致的可不在少数。
⽽协程可以帮你优雅的处理掉这些。
协程本⾝是⼀个脱离语⾔实现的概念,我们“很严谨”(哈哈)的给出的定义:
Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by
allowing execution to be suspended and resumed. Coroutines are well-suited for implementing familiar program
components such as cooperative tasks, exceptions, event loops, iterators, infinite lists and pipes.
简单来说就是,协程是⼀种⾮抢占式或者说协作式的计算机程序并发调度的实现,程序可以主动挂起或者恢复执⾏。这⾥还是需要有点⼉操作系统的知识的,我们在 Java 虚拟机上所认识到的线程⼤多数的实现是映射到内核的线程的,也就是说线程当中的代码逻辑在线程抢到CPU 的时间⽚的时候才可以执⾏,否则就得歇着,当然这对于我们开发者来说是透明的;⽽经常听到所谓的协程更轻量的意思是,协程并不会映射成内核线程或者其他这么重的资源,它的调度在⽤户态就可以搞定,任务之间的调度并⾮抢占式,⽽是协作式的。
关于并发和并⾏:正因为 CPU 时间⽚⾜够⼩,因此即便⼀个单核的 CPU,也可以给我们营造多任务同时运⾏的假象,这就是所谓的“并发”。并⾏才是真正的同时运⾏。并发的话,更像是 Magic。
如果⼤家熟悉 Java 虚拟机的话,就想象⼀下 Thread 这个类到底是什么吧,为什么它的 run ⽅法会运⾏在另⼀个线程当中呢?谁负责执⾏这段代码的呢?显然,咋⼀看,Thread 其实是⼀个对象⽽已,run ⽅法⾥⾯包含了要执⾏的代码——仅此⽽已。协程也是如此,如果你只是看标准库的 API,那么就太抽象了,但我们开篇交代了,学习协程不要上来去接触标准库,utines 框架才是我们⽤户应该关⼼的,⽽这个框架⾥⾯对应于 Thread 的概念就是 Job 了,⼤家可以看下它的定义:
public interface Job : CoroutineContext.Element{
...
public val isActive: Boolean
public val isCompleted: Boolean
public val isCancelled: Boolean
public fun start(): Boolean
public fun cancel(cause: CancellationException?=null)
public suspend fun join()
...
}
我们再来看看 Thread 的定义:
public class Thread implements Runnable {
...
public final native boolean isAlive();
public synchronized void start(){...}
@Deprecated
public final void stop(){...}
public final void join() throws InterruptedException {...}
...
}
这⾥我们⾮常贴⼼的省略了⼀些注释和不太相关的接⼝。我们发现,Thread 与 Job 基本上功能⼀致,它们都承载了⼀段代码逻辑(前者通过 run ⽅法,后者通过构造协程⽤到的 Lambda 或者函数),也都包含了这段代码的运⾏状态。
⽽真正调度时⼆者才有了本质的差异,具体怎么调度,我们只需要知道调度结果就能很好的使⽤它们了。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论