阅读 4380

Kotlin协程它不香吗?

本博客的目的:

  1. 知道Kotlin协程是什么,为什么要用Kotlin协程
  2. 快速上手Kotlin协程
  3. 抓住核心,避免被误导

Kotlin协程是什么

Kotlin的协程简单说就是线程的框架,详细点说它就是一套基于线程而实现的一套更上层的工具API

协程这个术语早在 1958 年就被发明并用于构建汇编程序,说明协程是一种编程思想,并不局限于特定的语言。比如Go 语言也有协程,叫 Goroutines

那为什么要用Kotlin协程呢?

我们经常会写出异步操作的代码,那么这时候就免不了要处理线程间的通信及切换。你可能会想到Android已经有一些很优秀的框架来帮我们做这些事情,比如AsyncTask。但它有一些缺点:

  • 它需要处理很多回调,一旦业务过多则容易陷入「回调地狱」。
  • 强行把业务拆分成了前台、中间更新、后台三个函数。

回调地狱指多个回调嵌套在一起

在写业务代码的时候,有好几个接口需要你使用,接口A需要接口B的回调结果作为参数去请求数据。这样就会形成回调函数的嵌套。如果有三四层回掉嵌套,最终就会长下面这个样子:

asyncFunc1(opt, (...args1)  {
    asyncFunc2(opt, (...args2) {
        asyncFunc3(opt, (...args3) {
            asyncFunc4(opt, (...args4)  {
                // some operation
            });
        });
    });
});
复制代码

看起来是不是有点恶心,如果你没有感觉到恶心,笔者觉得你可能经常写这样的代码所以习惯了,有一句“名人”名言:吐着吐着就习惯了。

到这里优秀的你肯定又想到了Rxjava这把利器,我们可以通过它提供的「Observable」的编程范式进行链式调用,可以很好地消除回调。

那么这里介绍的协程到底可以做什么呢?上面的问题它自然是可以解决了,那它相较于RxJava的优势是什么呢?

笔者觉得最主要的是它可以用看起来同步的方式写出异步的代码。这样写代码的人写起来很舒服,读代码的人读起来很畅快。

快速上手

下面笔者利用Retrofit配合协程实现一个登录功能

首先需要添加以下依赖库


implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
implementation 'com.squareup.retrofit2:retrofit:2.6.2'
implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
//为 Retrofit 添加对 Deferred 的支持
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
复制代码

根据 wanandroid 的登录API接口,通过retrofit框架敲出客户端的登录接口

接口API链接: www.wanandroid.com/blog/show/2

interface ApiService {
    companion object {
        const val BASE_URL = "https://www.wanandroid.com"
    }

    @FormUrlEncoded
    @POST("/user/login")
    fun login(@Field("username") username: String,
              @Field("password") password: String): Deferred<WanResponse<User>>
}
复制代码

Deferred是什么?它是Job的子接口。那,,,Job又是什么呢?可以简单理解,整个登录请求的过程就是会被封装成Job,然后交给协程调度器处理。但Job在完成的时候是没有返回值的,所以就有了Deferred,它的意思就是延迟,结果稍后才能拿到,它可以为任务完成时提供返回值。

根据请求后返回的json,写出返回值的数据类

data class WanResponse<out T>(val errorCode: Int,val errorMsg: String,val data: T)
复制代码
data class User(val collectIds: List<Int>,val email: String,
                val icon: String,val id: Int,
                val password: String, val type: Int, val username: String)
复制代码

之后构建一个retrofit实例,用它来进行请求登录

class ApiRepository {
    val retrofit = Retrofit.Builder()
        .baseUrl(ApiService.BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        //添加对Deffered的支持
        .addCallAdapterFactory(CoroutineCallAdapterFactory.invoke())
        .build()
        .create(ApiService::class.java)

    fun login(name: String,password: String): Deferred<WanResponse<User>>{
        return retrofit.login(name,password)
    }
}
复制代码

接下来主角协程要出场了。我们可以通过launch函数开启一个协程

GlobalScope.launch(Dispatchers.IO) {
  var result: WanResponse<User>?=null
  result = repository.login(userName,userPassword).await()
  launch(Dispatchers.Main) {
        btnLogin.text = result.data.username
    }
}
复制代码

这段代码出现了Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行,关于 Dispatchers 这里先不展开了。

常用的 Dispatchers ,有以下三种:

  • Dispatchers.Main:Android 中的主线程
  • Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
  • Dispatchers.Default:适合 CPU 密集型的任务,比如计算

但上面的栗子只是一次网络请求,如果有多次请求可能就变成这个样子:

GlobalScope.launch(Dispachers.IO) {
    //io操作
    launch(Dispachers.Main){
        //ui操作
        launch(Dispachers.IO) {
            //io操作
            launch(Dispacher.Main) {
              //ui操作
            }
        }
    }
}
复制代码

这个嵌套???不是说协程可以不用写嵌套代码的吗

于是协程中有一个很实用的函数:withContext这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行,

用 withContext 改写一下,它的结构大致就长这个亚子:

launch(Dispachers.Main) {
    ...
    withContext(Dispachers.IO) {
        ...
    }
    ...
    withContext(Dispachers.IO) {
        ...
    }
    ...
}
复制代码

比如上面的登录的栗子就可以改写成这样:

GlobalScope.launch(Dispatchers.Main) {
    var result: WanResponse<User>?=null
    withContext(Dispatchers.IO){
        //请求登录
       result = repository.login(userName,userPassword).await()
     }
      //更新ui
    btnLogin.text = result?.data?.username
   }
复制代码

好像的确变得简洁了许多,但离我们的目标:看起来同步的方式写出异步的代码还差那么一点。

既然不需要嵌套了,那就可以把io线程的操作,拿出来单独作为函数,就可以写成这样:

  suspend fun login(name: String,password: String): WanResponse<User> {
       return withContext(Dispatchers.IO) {
            val repository = ApiRepository()
            repository.login(name, password).await()
        }
    }
复制代码

这个函数和普通函数不一样,多出来一个关键字suspend,直译过来是挂起的意思,那这个关键字真正的作用到底什么呢?这个下面会详细解释,这里先跳过。

挂起函数写好了,那开启协程部分的代码就可以改写一下

   GlobalScope.launch (Dispatchers.Main){
                val result =login(userName,userPassword)
                btnLogin.text = result.data.username
    }
复制代码

这样看起来就和同步方式的代码一样了

还可以更简洁?

上面我们通过添加第三方依赖库来设配retrofit对kotlin协程的支持。其实是没有必要的,因为retrofit库的2.6.0版本之后就内置了对 Kotlin Coroutines 的支持,它帮我们简化了使用 Retrofit 和协程来进行网络请求的过程。

上面的代码最终可以改写成下面这个样子:

@FormUrlEncoded
@POST("/user/login")
suspend fun login(
@Field("username") username: String,@Field("password") password: String): WanResponse<User>
复制代码

可以看到只需要返回我们的WanResponse,不需要返回 Deferred<WanResponse>

suspend fun login(name: String,password: String): WanResponse<User>{
        return retrofit.login(name,password)
    }
复制代码

在挂起函数请求时也不需要自己调用await方法,因为retrofit已经帮我们在后面默默的调用了

suspend fun login(name: String, password: String): WanResponse<User> {
       return withContext(Dispatchers.IO) {
            val repository = ApiRepository()
            repository.login(name, password)
        }
    }
复制代码
GlobalScope.launch (Dispatchers.Main){
    val result =login(userName,userPassword)
    btnLogin.text = result.data.username
}
复制代码

注意:
为了方便理解,以上示例代码均没有处理异常情况,本篇博客也暂时不说,毕竟不是本文重点,而且异常处理要说详细一点可以单独再开一篇了。

supspend 关键字的作用

上面提到了挂起函数中的suspend,那它的作用是什么呢?是挂起作用?

如果是挂起作用,那它挂起的对象是什么?是当前线程还是所在的函数?

答案是都不是,协程中的挂起,本质上挂起的对象是协程。协程是啥?就是launch函数包起来的代码块。

 GlobalScope.launch (Dispatchers.Main){
        //login是个suspend函数
        val result = login(userName,userPassword)
        btnLogin.text = result.data.username
 }
      //Next
      ..... 
复制代码
  suspend fun login(name: String,password: String): WanResponse<User> {
       return withContext(Dispatchers.IO) {
            val repository = ApiRepository()
            repository.login(name, password).await()
        }
    }
复制代码

当执行到suspend函数的时候,该协程就会在当前线程中被挂起,通俗一点理解,当前线程暂时不管这个协程了。

那当前线程它去做什么呢?它该去做啥就做啥,比如还是上面的例子,由于该协程是在主线程中的,在请求登录时协程就会被挂起,主线程就从这个协程中脱离出来,继续走NEXT之后的代码。当登录请求成功之后,挂起函数又会将其切换到主线程中。

敲黑板,敲黑板啦

挂起其实做的就是稍后会将线程自动切换回来的操作,切换回来的动作就叫恢复(resume),它是协程里的功能,所以我们要在协程里面(这个协程当然也可以是一个挂起函数),去调用自带的挂起函数,比如常用的withContext()

到这里你很可能就认为suspend关键字的作用就是挂起协程的作用了,那就太高估它,它并没有这么神奇的功能。

suspend它本质上只是一个提醒,那么是谁对谁的提醒呢?

它是函数创建者对函数调用者的提醒,告诉函数调用者我是个挂起函数,是个耗时函数,请你在协程里面调用我。表面上它是一个要求,实际上它是一个提醒。

supend它并没有做挂起操作的功能,真正做挂起的是这个函数里面挂起函数,比如我们这里用的withContext这个自带的挂起函数。

所以值得注意的是:如果我们没有在supsend这个函数里面去使用挂起函数,那这个挂起函数就没有意义。因为一旦你使用了suspended关键字,就意味着它只能在协程中被调用。其实很容易理解:你又不需要挂起,还加个suspend让调用者只能在协程里面被调用,这不就相当于占着茅坑不拉shi一样[手动狗头]

简单总结一下:supsend的关键字它存在的意义就是提醒,在某种程度来说它可以限制调用者不在主线程做耗时操作。

如果创建一个 suspend 函数但它内部不包含真正的挂起逻辑,编译器会给一个提醒:redundant suspend modifier,告诉你这个 suspend 是多余的。

避开误区

1. 协程的挂起是非阻塞式,而线程是阻塞式的?

首先什么是非阻塞式?

简单点说非阻塞式就是不卡当前线程。这样看协程的确是非阻塞式,比如你在主线程遇到一个挂起函数,就被切到另一个线程去做操作了,那么你的主线程当然就不会被卡了。

那么问题来了,用Thread去切换线程是阻塞式的吗?

线程是阻塞式的这句话只在单线程情况下是对的,单线程的耗时操作肯定会卡线程,所以是阻塞式的。在多线程下,多线程的切换线程是不会卡线程的,所以肯定是非阻塞式的。

那单协程的挂起是阻塞式的吗?

它也是非阻塞式,因为它可以利用挂起函数来切线程。

所以,协程的挂起和线程的切换并没有什么区别,它们都是非阻塞方式。协程的挂起并不比线程的切换高级到哪里去,Kotlin的挂起就是切线程而已,和java的切线程本质上并没有区别的,除了它的这种“写法上看起来是阻塞式,实际非阻塞式的”神奇之处之外,并没有其他的神奇之处。

2.Kotlin协程的这种非阻塞式比线程更加高效?

如果你看懂了上面的内容,这句话也就不攻自破了,Kotlin协程并没有比线程高级,更不存在协程比线程高效一说。

小结

协程就是 Kotlin 提供的一套线程封装的 API,但并不是说协程就是为线程而生的。 协程设计的初衷是为了解决并发问题,让「协作式多任务」 实现起来更加方便。但初学Kotlin协程可以从线程控制切入,也就是本文介绍的内容,至于更高级的应用和实现原理,待笔者觉得如果有必要深入学习之后,再写博客分享~~

如果觉得笔者写的不容易理解,建议去看看扔物线大佬的Kotlin协程三连,肯定会让你有醍醐灌顶之感。

Kotlin的协程用力瞥一眼

本博客的内容可以算是看完协程三连后的学习笔记了~

关注下面的标签,发现更多相似文章
评论