Kotlin 协程从入门到真香

1. 前言

随着 Kotlin 的不断更新以及官方的推荐加持,越来越多的项目开始接受 Kotlin 作为主要的编写语言。但非常多的 Android 开发者依然只是停留在使用 Kotlin 的使用上,对于 Kotlin 的一些新鲜事物还保留观望态度,而 Kotlin 协程就是一个非常特别的事物。

虽然在 Go、Python 等编程语言上,早已有了协程的概念,但对于 Android 开发者来说,协程是一个非常新鲜的概念。于是就出现了一大批博客开始吹嘘协程有多么好用,性能有多么高,甚至一大批人都认为 Kotlin 协程必将代替 RxJava。恰好最近开言英语正好准备切换到 Kotlin 协程,这里就简单做些基本介绍,希望对大家理解有所帮助。

2. Kotin 的协程到底是什么?

当接触到一个全新的概念的时候,我们通常会比较懵逼,所以通常来说,我们都应该有一个概念认知。

那么协程到底是什么呢? 当在网上搜索协程时,我们会看到:

  • Kotlin 官方文档说「本质上,协程是轻量级的线程」。
  • 很多技术文章提到「协程是在用户态直接对线程进行管理」、「 Kotlin协程是一种异步编程的同步顺序写法」等等。

官方的定义太抽象,博客的定义太浮夸,以至于我们还是搞不懂,觉得晦涩难懂。

个人觉得虽然大多数时候我们要追求极致,但其实也并不需要咬文嚼字。简单地讲,Kotlin 的协程就是一个封装在线程上面的线程框架。

3. 协程的好处

Kotlin 协程可以用看起来同步的代码写出实质上异步的操作,当然如果你熟悉 Dart,你就会理解很深刻了。它有两个非常关键的亮点:

  • 耗时函数自动后台,从而提高性能;
  • 线程的「自动切回」

所以,Kotlin 的协程在 Android 开发上的核心好处就是:消除回调地域

4. 协程的使用

协程一般情况用于需要进行线程切换的场景中,对于我们 Android 开发者来说,网络请求应该是一个非常常见的场景了。

我们不妨来假设一下,我们需要做两个网络请求然后更新页面,此时我们如果用普通的网络请求写法一定是这样。

这里以 Retrofit 作为网络请求框架为例。

先奉上通用的代码:

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io()))
    .build()
val api = retrofit.create(Api::class.java)

再看看普通的网络请求代码:

api.listRepos("nanchen2251")
    .enqueue(object : Callback<List<Repo>?> {
        override fun onFailure(call: Call<List<Repo>?>, t: Throwable) {

        }

        override fun onResponse(call: Call<List<Repo>?>, response: Response<List<Repo>?>) {
            val name1 = response.body()?.get(0)?.name ?: ""
            api.listRepos("google")
                .enqueue(object : Callback<List<Repo>?> {
                    override fun onFailure(call: Call<List<Repo>?>, t: Throwable) {

                    }

                    override fun onResponse(
                        call: Call<List<Repo>?>,
                        response: Response<List<Repo>?>
                    ) {
                        val name2 = response.body()?.get(0)?.name ?: ""
                        val names = "$name1-$name2"
                        runOnUiThread{
                            // ... 使用 names 做页面更新操作
                        }
                        
                    }
                })
        }
    })

很明显,我们的需求其实是希望两个网络请求并行,然后再请求均完成后,更新页面。但我们通常的写法把原本应该并行的网络请求写成了串行,这让网络请求的时长远超正常的操作,显然是非常糟糕的。

当然我们可以考虑直接用 RxJava 来解决此类嵌套问题,用 RxJava 实现这个问题代码大致如下:

Single.zip(
    api.listReposRx("nanchen2251"),
    api.listReposRx("google"),
    BiFunction { repos1, repos2 -> "${repos1[0].name} - ${repos2[0].name}" }
).observeOn(AndroidSchedulers.mainThread())
    .subscribe(object : SingleObserver<String> {
        override fun onSuccess(combined: String) {
            val names = combined
            // ... 使用 names 做页面更新操作
        }

        override fun onSubscribe(d: Disposable) {

        }

        override fun onError(e: Throwable) {

        }
    })

可以发现,我们使用 Retrofit && RxJava 完美解决了串行请求带来的性能问题,而且代码也不再是回调地域,非常轻便。

那么如果我们使用 Kotlin 的协程,代码要怎么编写呢?

GlobalScope.launch(Dispatchers.Main) {
    val nanchen2251 = async { api.listReposKt("nanchen2251") }
    val google = async { api.listReposKt("google") }
    val name1 = nanchen2251.await()[0].name
    val name2 = google.await()[0].name
    // 更新页面
}

上面的代码很简洁,但要真看明白却并不简单,但我们大概能猜测这段代码如果运行没有问题的话,应该是先在子线程执行了两次网络请求,然后再将线程切换到了主线程进行页面的刷新。

这不由得让我们产生疑问:

  1. 这么简短的代码,真的可以做到线程的自动切换么?
  2. 这个 GlobalScope.launch 是干嘛的?
  3. 这个 async 又是什么关键字?

5. 协程的原理

5.1 协程的自动切换

可能你已经在其他地方听说了协程可以自动切换线程,但一定好奇它的实现原理,但真正当你去看协程的源码的时候,你会发现其实一脸懵逼,我们不妨思考一下,如果我们想实现一个可以自动切换线程的代码,我们会怎么做?

可能会是这样:

private val executor = ThreadPoolExecutor(5, 20, 1, TimeUnit.MINUTES, LinkedBlockingDeque())

private fun ioCode() {
    println("我是子线程执行的耗时代码")
}

private fun classicIoCode(uiThread: Boolean = true, block: () -> Unit) {
    println("我在主线程")
    executor.execute {
        ioCode()
        if (uiThread) {
            runOnUiThread {
                block()
            }
        } else {
            block()
        }
    }
}

协程的本质是还是线程池使用的上层封装,所以这么我们需要使用一个线程池的类 ThreadPoolExecutor。我们替换一个参数 uiThread 代表是否需要切换线程,当为 true 的时候,代表需要切回主线程执行后面的 lambda 表达式。

实际上,当你真正去查看协程的代码的时候,你会发现协程的原理大概也就是这样,只是代码的鲁棒性和拓展性比上面的代码更好。

如果我们去调用的话,可能代码是这样:

classicIoCode1(true) {
    uiCode()
}

private fun uiCode() {
    println("我是主线程执行的代码")
}

上面的代码,对应到协程写法就是:

GlobalScope.launch(Dispatchers.Main) {  // 主线程开启协程
    ioCode()
    uiCode()
}

private suspend fun ioCode() {
    withContext(Dispatchers.IO) {
        println("我是子线程执行的耗时代码")
    }
}

其运行过程为:


5.2 suspend 的本质

上面的代码中,出现了一个 suspend 关键字,很多同学在初学 Kotlin 的协程的时候,总是会误以为 suspend 这个关键字非常奇特,总会和协程的线程自动切换混淆在一起,甚至有的同学直接认为是 suspend 实现了线程切换。

其实并不是这样,suspend 的本质是什么?这里直接说结论:

  • suspend 并不是拿来切线程的;
  • 外面的协程告知的切回哪个线程,上下文信息
  • suspend 的关键作用:标记和提醒。

suspend 关键字用来标记方法是一个挂起的方法,挂起方法只能在协程、或者另外的挂起函数中调用。因为挂起的本身是协程操作,依赖协程的上下文。 但是 suspend 标记的函数并不是一定会有挂起操作,suspend 只是一个标记,在编译时能够生成协程对应的类,我们自定义的suspend标记的方法本身不执行任何挂起操作,执行挂起操作要执行框架给我们提供的挂起函数,如:delaywithContextasync 等。 如果我们不在方法里面执行这些挂起函数,那么 suspend 标记的意义只是,标记这个方法需要在协程里调用,编译器也会提示我们这个 suspend是不必要的。

值得强调的是:创建函数的人,一定要注意把其声明为 suspend 挂起函数,方便调用者 IDE 直接提示。

6. Kotlin 协程会替代 RxJava 吗

Kotlin 的协程是否让 RxJava 的响应式编程光辉不在了呢?答案取决于你询问的对象。狂信徒和营销者们会毫不犹豫地是是是。如果真是这样的话,开发者们迟早会将 Rx 代码用协程重写一遍,抑或从一开始就用协程来写。 因为 协程 目前还是实验性的,所以目前的诸如性能瓶颈之类的不足,都将逐渐解决。

RxJava 是响应式编程的佼佼者, 响应式编程的好处之一是大多数情况下都不必去理会诸如线程、取消信息的传递和操作符的结构等恼人的东西。RxJava 之类的库已经设计好了 API 并将这些底层的大麻烦封装起来了,通常情况下,程序员只需要使用即可。

对于多个耗时操作需要同步进行,RxJava 有 zip 关键字进行合并,而协程有 async && await。RxJava 把回调变成了链式,而 Kotlin 的协程直接去掉了回调,放弃了操作符,更加干净。

RxJava 和 Kotlin 的协程都有自己的优势所在,本质上 Kotlin 的协程也不是为了替代 RxJava,二者各有千秋。

Kotlin 的协程在 Jetpack 上组件增加了大量的协程应用场景,所以总的来说,还是推荐大家一起使用 Kotlin 的协程的。

7. Kotlin 协程如何避免「协漏」?

在 Android 开发中,耗时操作一般都需要在 onDestroy 方法中进行 cancel,如果不在页面销毁的时候进行 cancel 的话,大概率会发生协漏。

协漏:Kotlin 协程产生的内存协漏。

所以对于上面的代码,我们应该选择一个合适的时机进行耗时代码的取消。

对于 RxJava 的使用方式,我们可以把请求放入 CompositeDisposable 中,然后在 onDestroy 中进行取消。

private val disposable = CompositeDisposable()

override fun onCreate(savedInstanceState: Bundle?) {
  Single.zip(
        api.listReposRx("nanchen2251"),
        api.listReposRx("google"),
        BiFunction { repos1, repos2 -> "${repos1[0].name} - ${repos2[0].name}" }
    ).observeOn(AndroidSchedulers.mainThread())
        .subscribe(object : SingleObserver<String> {
            override fun onSuccess(combined: String) {
                val names = combined
                // ... 使用 names 做页面更新操作
            }

            override fun onSubscribe(d: Disposable) {
                disposable.add(d)
            }

            override fun onError(e: Throwable) {
                // ... 处理异常操作
            }
        })

}

override fun onDestroy() {
    disposable.dispose()
    super.onDestroy()
}

而在 Kotlin 的协程中,我们可以这样写:

private val jobs = ArrayList<Job>()

override fun onCreate(savedInstanceState: Bundle?) {
  val job = GlobalScope.launch(Dispatchers.Main) {
        val nanchen2251 = async { api.listReposKt("nanchen2251") }
        val google = async { api.listReposKt("google") }
        val name1 = nanchen2251.await()[0].name
        val name2 = google.await()[0].name
        // 更新页面
    }
    jobs.add()
}

override fun onDestroy() {
    jobs.forEach {
        it.cancel()
    }
    super.onDestroy()
}

实际上,我们在 Android 开发中,基本上不会使用 GlobalScope,这个只是一个全局性的 CoroutineScope,查看源码发现,我们其实有相当多的 CoroutineScope 实现。比如我们上面要执行在主线程的 Scope,我们可以直接使用 MainScope 进行替代,代码就变成了:

private val scope = MainScope()

override fun onCreate(savedInstanceState: Bundle?) {
    scope.launch {
        val nanchen2251 = async { api.listReposKt("nanchen2251") }
        val google = async { api.listReposKt("google") }
        val name1 = nanchen2251.await()[0].name
        val name2 = google.await()[0].name
        // 更新页面
    }
}

override fun onDestroy() {
    scope.cancel()
    super.onDestroy()
}

而如果你使用 Jetpack 组件的话,你会发现更简单,我们直接就可以使用 LifecycleCoroutineScope,比如上面代码直接变成了:

lifecycleScope.launch{
    val nanchen2251 = async { api.listReposKt("nanchen2251") }
    val google = async { api.listReposKt("google") }
    val name1 = nanchen2251.await()[0].name
    val name2 = google.await()[0].name
    // 更新页面
}

从此妈妈再也不用担心 Kotlin 的协程异步代码造成内存协漏了。

jetpack 对 Kotlin 的协程支持还有很多,比如 Lifecycle、ViewModel、LiveData、Room 都对协程有所支持,感兴趣的可以到官网进行查阅。

8. 总结

Kotlin 协程并没有脱离 Kotlin 或者 JVM 创造新的东西,他就是 kotlin 提供给我们的一套线程操作 API,其所有魔法都来自线程。

协程利用挂起代替阻塞,让我们能用同步的方式写出异步的代码,代码结构更加清晰,从语法上看它很神奇,但从原理上讲,本质上就是线程切换,通过切走再切回来的操作,代替回调,抹平代码的层级。

协程通过 suspend 关键字,将方法是否耗时在创建时就区分出来,确定耗时方法只能在协程上调用,从机制上避免了卡顿,防止一不小心在主线程调用了耗时代码。对于规范工程代码,减少程序 ANR 有极大的帮助。 希望通过这篇文章能帮助大家上手 Kotlin 协程,消除对于协程的误解,不再觉得害怕不敢上手,能够真正通过协程提高代码质量,优化代码结构,必要的时候还能用它来提升性能。到时候真的能从心底里说一声:Kotlin 的协程,真香!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345