不过,我们学习 Kotlin 中的协程,一开始确实可以从线程控制的角度来切入。因为在 Kotlin 中,协程的一个典型的使用场景就是线程控制。就像 Java 中的Executor
和 Android 中的AsyncTask
,Kotlin 中的协程也有对 Thread API 的封装,让我们可以在写代码时,不用关注多线程就能够很方便地写出并发操作。
下面的例子是使用协程进行网络请求获取用户信息并显示到 UI 控件上:
launch({
val user = api.getUser() // 👈 网络请求(IO 线程)
nameTv.text = user.name // 👈 更新 UI(主线程)
})
这里只是展示了一个代码片段,launch
并不是一个顶层函数,它必须在一个对象中使用,launch 函数加上实现在 {} 中具体的逻辑,就构成了一个协程。
通常我们做网络请求,要不就传一个 callback,要不就是在 IO 线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,但写法上看起来和普通的单线程代码一样。
这里的api.getUser
是一个挂起函数
,所以能够保证nameTv.text
的正确赋值,这就涉及到了协程中最著名的「非阻塞式挂起」。这个名词看起来不是那么容易理解,后面会讲。现在先把这个概念放下,只需要记住协程就是这样写的就行了。
这种「用同步的方式写异步的代码」看起来很方便吧,那么我们来看看协程具体好在哪。
2.协程好在哪
前面提到,launch 函数不是顶层函数,是不能直接用的,可以使用下面三种方法来创建协程:
// 方法一,使用 runBlocking 顶层函数
runBlocking {
getImage(imageId)
}
// 方法二,使用 GlobalScope 单例对象
// 👇 可以直接调用 launch 开启协程
GlobalScope.launch {
getImage(imageId)
}
// 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象
// 👇 需要一个类型为 CoroutineContext 的参数
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
getImage(imageId)
}
-
方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
-
方法二和使用 runBlocking 的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会和 app 一致,且不能取消(什么是协程的取消后面会讲)。
-
方法三是比较推荐的使用方法,我们可以通过 context 参数去管理和控制协程的生命周期(这里的 context 和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用)。
协程最常用的功能是并发,而并发的典型场景就是多线程。可以使用 Dispatchers.IO
参数把任务切到 IO 线程执行:
CoroutineScope(Dispatchers.IO).launch {
…
}
也可以使用 Dispatchers.Main
参数切换到主线程:
CoroutineScope(Dispatchers.Main).launch{
…
}
所以在「协程是什么」一节中讲到的异步请求的例子完整写出来是这样的:
CoroutineScope(Dispatchers.Main).launch{ // 在主线程开启协程
val user = api.getUser() // IO 线程执行网络请求
nameTv.text = user.name // 主线程更新 UI
}
而通过 Java 实现以上逻辑,我们通常需要这样写:
api.getUser(new Callback() {
@Override
public void success(User user) {
runOnUiThread(new Runnable() {
@Override
public void run() {
nameTv.setText(user.name);
}
})
}
@Override
public void failure(Exception e) {
…
}
});
这种回调式的写法,打破了代码的顺序结构和完整性,读起来相当难受。
3.协程具体怎么用
a.添加依赖
//依赖协程核心库
implementation ‘org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5’
//依赖当前平台所对应的平台库
implementation ‘org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.5’
Kotlin 协程是以官方扩展库的形式进行支持的。我们所使用的「核心库」和 「平台库」的版本应该保持一致。
-
核心库中包含的代码主要是协程的公共 API 部分。有了这一层公共代码,才使得协程在各个平台上的接口得到统一。
-
平台库中包含的代码主要是协程框架在具体平台的具体实现方式。因为多线程在各个平台的实现方式是有所差异- 的。
b.开始使用
协程最简单的使用方法,其实在前面章节就已经看到了。我们可以通过一个 launch
函数实现线程切换的功能:
CoroutineScope(Dispatchers.IO).launch {
…
}
这个 launch 函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给 launch 的那些代码,这一段连续代码叫做一个「协程」。
所以,什么时候用协程?当你需要切线程或者指定线程的时候。你要在后台执行任务?切!
launch(Dispatchers.IO) {
val image = getImage(imageId)
}
然后需要在前台更新界面?再切!
CoroutineScope(Dispatchers.IO).launch{
val image = getImage(imageId)
launch(Dispatch.Main) {
avatarIv.setImageBitmap(image)
}
}
好像有点不对劲?这不还是有嵌套嘛。
如果只是使用 launch 函数,协程并不能比线程做更多的事。不过协程中却有一个很实用的函数:withContext
。这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行。那么可以将上面的代码写成这样:
CoroutineScope(Dispatchers.Main).launch { // 👈 在 UI 线程开始
val image = withContext(Dispatchers.IO) { // 👈 切换到 IO 线程,并在执行完成后切回 UI 线程
getImage(imageId) // 👈 将会运行在 IO 线程
}
avatarIv.setImageBitmap(image) // 👈 回到 UI 线程更新 UI
}
这种写法看上去好像和刚才那种区别不大,但如果你需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:
// 第一种写法
CoroutineScope(Dispatchers.IO).launch {
…
launch(Dispachers.Main){
…
launch(Dispachers.IO) {
…
launch(Dispacher.Main) {
…
}
}
}
}
// 通过第二种写法来实现相同的逻辑
CoroutineScope(Dispatchers.Main).launch{
…
withContext(Dispachers.IO) {
…
}
…
withContext(Dispachers.IO) {
…
}
…
}
由于可以"自动切回来",消除了并发代码在协作时的嵌套。由于消除了嵌套关系,我们甚至可以把 withContext
放进一个单独的函数里面:
launch(Dispachers.Main) { // 👈 在 UI 线程开始
val image = getImage(imageId)
avatarIv.setImageBitmap(image) // 👈 执行结束后,自动切换回 UI 线程
}
// 👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
…
}
这就是之前说的「用同步的方式写异步的代码」了。
不过如果只是这样写,编译器是会报错的:
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
// IDE 报错 Suspend function’withContext’ should be called only from a coroutine or another suspend funcion
}
意思是说,withContext 是一个 suspend
函数,它需要在协程或者是另一个 suspend
函数中调用。
c.suspend
suspend
是 Kotlin 协程最核心的关键字,几乎所有介绍 Kotlin 协程的文章和演讲都会提到它。它的中文意思是「暂停」或者「可挂起」。如果你去看一些技术博客或官方文档的时候,大概可以了解到:「代码执行到 suspend
函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。」
上面报错的代码,其实只需要在前面加一个suspend
就能够编译通过:
suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
…
}
而suspend
具体是什么,下面介绍。
4.小结
协程是一种编程思想,写法简洁,可以通过Dispatchers调度器切换到指定的线程。所有代码都是运行在线程中的,协程也是。
==================================================================
1.「挂起」的本质
协程中「挂起」的对象到底是什么?挂起线程,还是挂起函数?都不对,我们挂起的对象是协程。
launch ,async 或者其他函数创建的协程,在执行到某一个 suspend 函数的时候,这个协程会被「suspend」,也就是被挂起。
那此时又是从哪里挂起?从当前线程挂起。换句话说,就是这个协程从正在执行它的线程上脱离。
注意,不是这个协程停下来了!是脱离,当前线程不再管这个协程要去做什么了。
suspend 是有暂停的意思,但我们在协程中应该理解为:当线程执行到协程的 suspend 函数的时候,暂时不继续执行协程代码了。
我们先让时间静止,然后兵分两路,分别看看这两个互相脱离的线程和协程接下来将会发生什么事情:
线程:
前面我们提到,挂起会让协程从正在执行它的线程上脱离,具体到代码其实是:
协程的代码块中,线程执行到了 suspend 函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块。
那线程接下来会做什么呢?
如果它是一个后台线程:
-
要么无事可做,被系统回收
-
要么继续执行别的后台任务
跟 Java 线程池里的线程在工作结束之后是完全一样的:回收或者再利用。
如果这个线程它是 Android 的主线程,那它接下来就会继续回去工作:也就是一秒钟 60 次的界面刷新任务。
协程:
线程的代码在到达suspend
函数的时候被掐断,接下来协程会从这个suspend
函数开始继续往下执行,不过是在指定的线程
。
谁指定的?是 suspend 函数指定的,比如函数内部的 withContext
传入的Dispatchers.IO
所指定的 IO 线程。
Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行。
常用的Dispatchers
,有以下三种:
-
Dispatchers.Main:Android 中的主线程
-
Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
-
Dispatchers.Default:适合 CPU 密集型的任务,比如计算
回到我们的协程,它从suspend
函数开始脱离启动它的线程,继续执行在 Dispatchers
所指定的 IO 线程。
紧接着在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:会自动帮我们把线程再切回来
。
这个「切回来」是什么意思?
我们的协程原本是运行在主线程的,当代码遇到suspend
函数的时候,发生线程切换,根据 Dispatchers
切换到了 IO 线程;
当这个函数执行完毕后,线程又切了回来,「切回来」也就是协程会帮我再 post
一个 Runnable
,让我剩下的代码继续回到主线程去执行。
ok,我们从线程和协程的两个角度都分析完成后,终于可以对协程的「挂起」suspend 做一个解释:
协程在执行到有 suspend 标记的函数的时候,会被 suspend 也就是被挂起,而所谓的被挂起,就是切个线程;
不过区别在于,挂起函数在执行完成之后,协程会重新切回它原先的线程。
再简单来讲,在 Kotlin 中所谓的挂起,就是一个稍后会被自动切回来的线程调度操作。
这个「切回来」的动作,在 Kotlin 里叫做 resume,恢复。
通过刚才的分析我们知道:挂起之后是需要恢复。
而恢复这个功能是协程的,如果你不在协程里面调用,恢复这个功能没法实现,所以也就回答了这个问题:为什么挂起函数必须在协程或者另一个挂起函数里被调用。
因为 一个挂起函数要么在协程里被调用,要么在另一个挂起函数里被调用,所以不管是直接还是间接地,总是会在一个协程里被调用的。
当然,要求 suspend 函数只能在协程里或者另一个 suspend 函数里被调用,还是为了要让协程能够在 suspend 函数切换线程之后再切回来。
2.怎么就「挂起」了?
我们了解到了什么是「挂起」后,再接着看看这个「挂起」是怎么做到的。
先随便写一个自定义的 suspend
函数:
suspend fun suspendingPrint() {
println(“Thread: ${Thread.currentThread().name}”)
}
I/System.out: Thread: main
输出的结果还是在主线程。
为什么没切换线程?因为它不知道往哪切,需要我们告诉它。
比如例子中 suspendingGetImage
函数代码:
// 👇
suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
…
}
我们可以发现不同之处其实在于 withContext
函数。
其实通过 withContext
源码可以知道,它本身就是一个挂起函数,它接收一个 Dispatcher
参数,依赖这个 Dispatcher 参数的指示,你的协程被挂起,然后切到别的线程。
所以这个 suspend,其实并不是起到把任何把协程挂起,或者说切换线程的作用。
真正挂起协程这件事,是 Kotlin 的协程框架帮我们做的。
所以我们想要自己写一个挂起函数,仅仅只加上 suspend 关键字是不行的,还需要函数内部直接或间接地调用到 Kotlin 协程框架自带的 suspend 函数才行。
3.suspend 的意义?
这个 suspend 关键字,既然它并不是真正实现挂起,那它的作用是什么?
它其实是一个提醒。
函数的创建者对函数的使用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我。
为什么 suspend
关键字并没有实际去操作挂起,但 Kotlin 却把它提供出来?
因为它本来就不是用来操作挂起的。
挂起的操作 —— 也就是切线程,依赖的是挂起函数里面的实际代码,而不是这个关键字。
所以这个关键字,只是一个提醒。
还记得刚才我们尝试自定义挂起函数的方法吗?
// 👇 redundant suspend modifier
suspend fun suspendingPrint() {
println(“Thread: ${Thread.currentThread().name}”)
文末
好了,今天的分享就到这里,如果你对在面试中遇到的问题,或者刚毕业及工作几年迷茫不知道该如何准备面试并突破现状提升自己,对于自己的未来还不够了解不知道给如何规划,可以来看看同行们都是如何突破现状,怎么学习的,来吸收他们的面试以及工作经验完善自己的之后的面试计划及职业规划。
这里放上一部分我工作以来以及参与过的大大小小的面试收集总结出来的相关的几十套腾讯、头条、阿里、美团等公司21年的面试专题,其中把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分免费分享给大家,主要还是希望大家在如今大环境不好的情况下面试能够顺利一点,希望可以帮助到大家~
还有 高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
【Android核心高级技术PDF文档,BAT大厂面试真题解析】
【延伸Android必备知识点】
这里只是整理出来的部分面试题,后续会持续更新,希望通过这些高级面试题能够降低面试Android岗位的门槛,让更多的Android工程师理解Android系统,掌握Android系统。喜欢的话麻烦点击一个喜欢在关注一下~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!
好了,今天的分享就到这里,如果你对在面试中遇到的问题,或者刚毕业及工作几年迷茫不知道该如何准备面试并突破现状提升自己,对于自己的未来还不够了解不知道给如何规划,可以来看看同行们都是如何突破现状,怎么学习的,来吸收他们的面试以及工作经验完善自己的之后的面试计划及职业规划。
这里放上一部分我工作以来以及参与过的大大小小的面试收集总结出来的相关的几十套腾讯、头条、阿里、美团等公司21年的面试专题,其中把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分免费分享给大家,主要还是希望大家在如今大环境不好的情况下面试能够顺利一点,希望可以帮助到大家~
还有 高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
【Android核心高级技术PDF文档,BAT大厂面试真题解析】
[外链图片转存中…(img-zZaPcy88-1715844448227)]
【延伸Android必备知识点】
[外链图片转存中…(img-gzywGmol-1715844448230)]
这里只是整理出来的部分面试题,后续会持续更新,希望通过这些高级面试题能够降低面试Android岗位的门槛,让更多的Android工程师理解Android系统,掌握Android系统。喜欢的话麻烦点击一个喜欢在关注一下~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!