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
// 更新页面
}
上面的代码很简洁,但要真看明白却并不简单,但我们大概能猜测这段代码如果运行没有问题的话,应该是先在子线程执行了两次网络请求,然后再将线程切换到了主线程进行页面的刷新。
这不由得让我们产生疑问:
- 这么简短的代码,真的可以做到线程的自动切换么?
- 这个 GlobalScope.launch 是干嘛的?
- 这个 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标记的方法本身不执行任何挂起操作,执行挂起操作要执行框架给我们提供的挂起函数,如:delay
,withContext
,async
等。 如果我们不在方法里面执行这些挂起函数,那么 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 的协程,真香!。