前言
协程的重要性
协程是Kotlin对比Java的最大优势,Kotlin的协程可以极大地简化并发编程和优化软件架构。这里的简化异步编程主要就体现在协程可以利用挂起函数实现使用同步的代码实现异步的操作(避免回调地狱),而优化软件架构则是可以利用协程的结构化并发特性和许多诸如Flow等API来简化复杂的逻辑。
所以学习协程,最直接的价值就是多了一个处理并发编程的方法,让之前用线程实现的逻辑改用协程;
但是在我看来,学习协程最重要的是理解这个框架的设计理念,比如在之前线程中不好处理的业务,使用协程如何优化,协程为什么要这样设计;所以要站在Kotlin协程的创建者角度上,来解析其设计理念,构建一套完整的知识体系,建立一个具体的协程思维模型,提高我们的架构思维高度。
什么是协程
首先协程是一个非常早的概念,而且在其他很多语言中都有,Kotlin也是最近几年才支持的,所以我们先从广义上说,使用简单的语言来描述协程就是:互相协作的程序。
可以看出这里和普通程序不同的点就是可以互相协作,那怎么互相协作呢 我们来举个例子看一下。
协程能够解决什么痛点问题
Kotlin 的协程采用了一种新的并发方式,可以在帮我们Android上简化异步代码。
虽然在 Kotlin 1.3 协程作为全新特性出现的,但是协程的概念从编程语言诞生之初就已经存在了。第一个探索使用协程的语言是的 Simula ,出现在 1967年。
最近几年,协程越来越受欢迎,现在许多流行的编程语言里都有协程,如Go、 Javascript、C#、Python、Ruby等等,Kotlin 协程的设计基于它们这些构建过大型应用的经验。
在 Android 上,协程可以非常好的解决两个问题:
- 防止耗时任务在主线程运行过久,阻塞主线程
- 从主线程上安全地去调用网络或磁盘操作
下面让我们深入这两个问题,看看协程是如何帮助我们写出更简洁的代码。
耗时任务
同后台接口交互都需要访问网络。同样的,访问数据库或从硬盘加载图片都需要读取文件。这些就是我说的耗时任务——这些任务耗时太长,导致你的应用卡顿。
很难想象,现代手机执行代码相比网络请求有多快。在 Pixel 2上,一次 CPU 周期只需要 0.0000004 秒,这个数字从人类的角度上很难理解。然而,如果你把一次网络请求看成一眨眼的时间,差不多 400 毫秒(0.4秒),这就比较好理解 CPU 执行有多快了。一次眨眼的时间,或者稍微慢一点的网络请求中, CPU 可以执行超过 100万个周期。
在 Android 上,每个应用程序都有一个主线程负责处理 UI (比如绘制视图)和与用户交互。如果在这个线程上做了太多工作,应用程序就会出现卡顿或者响应缓慢,从而导致不好的用户体验。任何耗时任务都不应该阻塞主线程,这样你的应用就能避免例如触摸反馈时响应缓慢的卡顿。
为了在主线程以外执行网络请求,一个常见的模式是 Callback,Callback 给了 library 一个 handle,它可以用来在将来的某个时候调用你的代码。使用 Callback 访问 developer.android.com 看起来类似这样:
class ViewModel: ViewModel() {
fun fetchDocs() {
get("developer.android.com") { result ->
show(result)
}
}
}
即使在主线程调用 get ,它也会在另一个线程执行网络请求。然后,一旦从网络中获取到结果,就会在主线程上调用回调。这是处理耗时任务的好办法,而通过 Retrofit 可以帮助你在其他线程发出网络请求。
使用协程做耗时操作
协程可以简化耗时任务,例如 fetchDocs 的代码。为了展示协程如何简化耗时任务的代码,让我们使用协程来重写上面的 Callback 示例
// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.IO
val result = get("developer.android.com")
// Dispatchers.Main
show(result)
}
// look at this in the next section
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}
为什么这个代码不会阻塞主线程?它怎么在不等待网络请求和阻塞的情况下返回 get 的结果?事实证明,协程为 Kotlin 提供了一种方式来执行这段代码,并且不会阻塞主线程。
协程在常规函数的基础上加了两个新的操作符。除了 invoke (or call) 和 return 以外,协程还添加了 suspend 和 resume。
- suspend——暂停当前协程的执行,保存所有本地变量
- resume——让挂起的协程从暂停的地方恢复执行
Kotlin 通过函数上的 suspend 关键字来添加这个功能。你只能从其他挂起函数调用挂起函数,或者使用协程启动器类似 launch 来启动一个新的协程。
在上面的例子中,get 会在启动网络请求之前被 挂起 。然后get函数会脱离主线程继续负责运行网络请求。然后,当网络请求完成时,它不用回调来通知主线程,而是简单的 恢复 挂起的协程。
查看fetchDocs是如何执行的,你可以看到 挂起 是如何工作的。当协程被挂起时,当前堆栈帧(Kotlin 用来跟踪某个函数正在运行的位置及其变量)将会被复制并保存,用来以后使用。当它恢复,这个堆栈帧会被复制回来,并重新运行。在动画的中间——当主线程上所有协程被挂起时,主线程可以自由地更新屏幕并处理用户事件。挂起配合恢复替换了回调,非常简洁。
当主线程上的所有协程都挂起时,主线程可以自由地执行其他工作。
When all of the coroutines on the main thread are suspended, the main thread is free to do other work.
即使我们编写了与阻塞网络请求完全相同的直接顺序的代码,协程也将按照我们希望的方式运行我们的代码,并且避免阻塞了主线程!
接下来,让我们看看如何使用协程实现主线程安全(main-safety),并探索调度流程。
主线程安全与协程
在 Kotlin 协程中,编写合适的挂起函数需要从主线程调用总是安全的。无论它们会做什么,都应该始终允许任何线程可以去调用它们。
但是,我们在安卓应用中做的很多事情,对于主线程来说都太慢了。网络请求、解析 JSON 、读写数据库,甚至只是遍历大型列表。其中任何一个都有可能因为太慢导致用户可以察觉到的延迟,所以应该脱离主线程运行。
使用 挂起 不是告诉 Kotlin 在一个后台线程挂起。值的一提的说,协程通常在主线程运行。实际上,在响应一个 UI 事件的时候,使用 Dispatchers.Main.immediate 启动一个协程是一个非常好的主意——这样,如果你最终没有在主线程执行耗时任务,那么结果就会在下一帧提供给用户。
协程将运行在主线程,并且挂起不代表在后台
Coroutines will run on the main thread, and suspend does not mean background.
这样的方式操作一个函数,会让主线程变慢,你可以告诉 Kotlin 协程在 Default 调度器或者 IO 调度器上执行工作。
在 Kotlin 中,所有的协程必须通过调度器运行,即使它们运行在主线程上。协程可以 挂起 自己,而调度器知道怎么 恢复 它们。
要指定协程应该运行在哪里,Kotlin 提供了三个可以用于切换线程的调度器。
+-----------------------------------+
| Dispatchers.Main |
+-----------------------------------+
| Main thread on Android, interact |
| with the UI and perform light |
| work |
+-----------------------------------+
| - Calling suspend functions |
| - Call UI functions |
| - Updating LiveData |
+-----------------------------------+
+-----------------------------------+
| Dispatchers.IO |
+-----------------------------------+
| Optimized for disk and network IO |
| off the main thread |
+-----------------------------------+
| - Database* |
| - Reading/writing files |
| - Networking** |
+-----------------------------------+
+-----------------------------------+
| Dispatchers.Default |
+-----------------------------------+
| Optimized for CPU intensive work |
| off the main thread |
+-----------------------------------+
| - Sorting a list |
| - Parsing JSON |
| - DiffUtils |
+-----------------------------------+
- 如果你使用 挂起函数, RxJava, 或 LiveData,Room 会提供自动的主线程安全。
** 网络库(如 Retrofit 和 Volley)会管理它们自己的线程,当与 Kotlin 协程一起使用时,不需要在代码中显式地声明主线程安全。
继续上面的示例,让我们使用调度器来定义 get 函数。在 get 的函数体中,我们调用 withContext(Dispatchers.IO) 用来创建一个运行在 IO 调度器 的代码块。你写在这个代码块中的所有代码都始终将在 IO 调度器上运行。由于 withContext 本身是一个挂起函数,所以它将使用协程来保证主线程安全。
// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.Main
val result = get("developer.android.com")
// Dispatchers.Main
show(result)
}
// Dispatchers.Main
suspend fun get(url: String) =
// Dispatchers.IO
withContext(Dispatchers.IO) {
// Dispatchers.IO
/* perform blocking network IO here */
}
// Dispatchers.Main
使用协程,你可以对线程进行细粒度的划分(With coroutines you can do thread dispatch with fine-grained control)。因为withContext 允许你控制在什么线程上执行任何代码块,而不需要引入 callback 来返回结果,所以你可以将它应用于非常小的函数,比如从数据库读取数据或执行网络请求。因此,一个好的实践是使用 withContext 来确保任何调度器(包括 Main)上调用每个函数都是安全的——这样调用者就不必考虑需要在哪个线程执行函数。
在这个例子上,fetchDocs 在主线程上执行,但是可以安全的调用get 函数,然后会在后台执行网络请求。因为协程支持挂起和恢复,所以只要 withContext块完成,主线程上的协程就会被恢复得到结果。
写的好的挂起函数从主线程调用总是安全的。
- Well written suspend functions are always safe to call from the main thread (or main-safe).
让每个挂起函数在主线程调用都是安全的是个好主意。如果它做了任何触及磁盘、网络甚至只是占有太多 CPU 的操作,那么就使用 withContext 来确保从主线程调用是安全。这是基于协程的库(如 Retrofit 和 Room)所遵循的模式。如果你在整个代码库中都遵循这种风格,那么你的代码将会简单的多,并避免将线程问题和应用程序逻辑混合在一起。当遵循这个模式时,协程可以在主线程上自由调用,用简单的代码请求网络或数据库,同时保证用户不会看到卡顿。
下篇是什么
在这篇文章中,我们探讨了协程最擅长解决的问题。协程在编程语言中是一个存在很久的概念,由于它能够简化与网络交互的代码,所以最近变得非常流行。
在 Android 上,你可以使用它们解决两个非常常见的问题:
-
简化耗时任务的代码,比如从网络、磁盘读取数据,甚至解析大型 JSON 结果。
-
执行精确的主线程安全,以确保不会意外阻塞主线程,而不会使代码难以读和写
1.问题
Kotlin 1.3 版本开始引入协程 Coroutine,由于协程在 Kotlin 中是非常特别的一部分,和 Java 相比,它是一个比较新颖的概念,所以刚开始很多人很难理解协程这一概念,所以首先在讲协程之前,我们需要先搞清楚几个概念,有助于后续我们更好的理解协程。
1.1、什么是并发?什么是并行?
1)、并发就是同一时刻只有一条指令在执行,但是因为 CPU 时间片非常的小,多个指令间能够快速的切换,使得我们看起来拥有同时执行的效果,存在于单核或多核 CPU 系统中。
2)、并行就是同一时刻有多条指令同时在执行,存在于多核 CPU 系统中
举个生活中人吃馒头的例子:一个人买了 3 个馒头,那么他同一时刻只能在吃一个馒头,这是并发。而 3 个人每人买了一个馒头,那么同一时刻他们能同时吃馒头,这是并行,并发和并行的区别在于同一时刻任务是否在同时进行。
1.2、什么是多任务?什么是协作式多任务?什么是抢占式多任务?
1)、多任务就是操作系统能够同时处理多个任务,例如我可以使用电脑打开 AndroidStudio 和网易云音乐,一边撸码一边听歌。
2)、协作式多任务就是一个任务得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU ,所以任务之间需要协作,使用一段时间的 CPU 后,放弃使用,其它的任务也如此,才能保证系统的正常运行。一般出现在早期的操作系统中,如 Windows 3.1。
3)、抢占式多任务就是由操作系统来分配每个任务的 CPU 使用时间,在一个任务使用一段时间 CPU 后,操作系统会剥夺当前任务的 CPU 使用权,把它排在询问队列的最后,再去询问下一个任务。一般出现在现在使用的操作系统,如 Window 95及之后的 Windows 版本
协作式多任务和抢占式多任务区别:在协作式多任务中,如果一个任务死锁,则系统也会死锁。而抢占式多任务中,如果一个任务死锁,系统仍能正常运行。
1.3 什么是同步?什么是异步?
先撇开编程相关的东西,我们通过 坐公交 的例子帮助理解同步与异步:
乘客排队等公交,车来了,前门扫码上车,一个扫完到下一个扫,一种 串行化 的关系,这是 同步;
前门乘客上车,后门乘客下车,互不影响,同时进行,一种 并行化 的关系,这是 异步
我们把乘客上车和下车,看做是两个 任务,司机开车也是一个任务,跟这两个任务是异步关系。异步说明两者
可以同时进行,乘客还没上完车,司机直接把车开走,也是可以的:
不过这显然不合常理,正常来说:司机应该等乘客上下车完毕才发车,那司机怎么知道:
常规操作有两种:
轮询(主动):每隔一段时间查看下前后门监控,看下还有没有乘客;
回调(被动):早期的公交车上都会配有一个乘车员,没乘客上下车了,她就会喊司机开车;
计算机领域中的同步和异步
1)、计算机领域中的同步就是当调用者发送一个调用指令,需等待该指令执行完,在继续往下执行,是一种串行的处理方式。
2)、计算机领域中的异步就是当调用者发送一个调用指令,无需等待该指令执行完,继续往下执行,是一种并行的处理方式。
1.4、什么是阻塞?什么是非阻塞?
同步和异步的关注点是 是否同时进行,而阻塞和非阻塞关注的是 能否继续进行,还是以坐公交为例:
有乘客上下车,司机发车就需要**等待**,此时司机发车的任务处于 阻塞 状态;
乘客都上下车完毕,司机又可以发车了,此时司机发车的任务处于 非阻塞 状态;
阻塞的真正含义:关心的事物由于某些原因,无法继续进行,因此让你等待。
等待:只是堵塞的一个副作用,表明随时间流逝,没有任何有意义的事物发生或进行。
阻塞时,没必要干等着,可以做点其他无关的事物,因为这不影响你对相关事情的等待;
比如司机等发车时,可以喝喝茶、看看手机等,但不能离开。
计算机没人那么灵活,阻塞时干等最容易实现,只需挂起线程,让出CPU即可,等条件满足时,在重新调度此线程。
在 Android 中阻塞,其实就是卡住了主线程的运行,那么非阻塞就是没有卡住主线程的运行。
1.5、什么是挂起?
挂起就是保存当前状态,等待恢复执行,在 Android 中的体现,挂起就是不影响主线程的工作,更贴切的说法可以理解为切换到了一个指定的线程。
1.6、什么是非阻塞式挂起?
通过上面概念的解释,非阻塞式挂起就是不会卡住主线程且将程序切换到另外一个指定的线程去执行。
1.7、什么是协程?
协程,英文名 Coroutine,源自 Simula 和 Modula-2 语言,它是一种协作式多任务实现,是一种编程思想,并不局限于特定的语言。协程设计的初衷是为了解决并发问题,让协作式多任务实现起来更加方便。
Go 语言也有协程,叫 Goroutines,从英文拼写就知道它和 Coroutines 还是有些差别的(设计思想上是有关系的),否则 Kotlin 的协程完全可以叫 Koroutines 了。
1.8 什么是 Kotlin 协程?
Kotlin 协程简单来说是一套线程操作框架,详细点说它就是一套基于线程而实现的一套更上层的工具 API,类似于 Java 的线程池,你可以理解 Kotlin 新造了一些概念用来帮助你更好地使用这些 API,仅此而已。
当我们讨论协程和线程的关系时,很容易陷入中文的误区,两者都有一个「程」字,就觉得有关系,其实就英文而言,Coroutines 和 Threads 就是两个概念。
从 Android 开发者的角度去理解它们的关系:
我们所有的代码都是跑在线程中的,而线程是跑在进程中的。
协程没有直接和操作系统关联,但它不是空中楼阁,它也是跑在线程中的,可以是单线程,也可以是多线程。
单线程中的协程总的执行时间并不会比不用协程少。
Android 系统上,如果在主线程进行网络请求,会抛出 NetworkOnMainThreadException,对于在主线程上的协程也不例外,这种场景使用协程还是要切线程的。
1.9 Kotlin 协程有什么用?
-
协程可以降低异步程序的设计复杂度,需要注意的是协程不能让代码异步,只能让异步代码更简单
-
挂起和恢复可以控制执行流程的转移(比如网络请求时可以从UI线程切换到IO线程,执行完回调结果又可以切换回UI线程)
-
异步逻辑可以用同步代码的形式写出
-
同步代码比异步代码更灵活,更容易实现复杂业务
单线程的Android GUI系统
是的,Android GUI 被设计成单线程了,你可能会问:为啥不采用性能更高的多线程?
如果设计成多线程,多个线程同时对一个UI控件进行更新,容易发生 线程同步安全问题;最简单的解决方式:加锁,但这意味着更多的耗时和UI更新效率的降低,而且还有死锁等诸多问题要解决;多线程模型带来的复杂度成本,远远超出它能提供的性能优势成本。这也是大部分GUI系统都是单线程模型的原因。
Android要求:在主线程(UI线程)更新UI,注意是 → 要求建议,不是规定,规定底线是:
只有创建这个view的线程才能操作这个view
所以,你在子线程中更新子线程创建的UI也是可以的,不过不建议这么做,建议:
在子线程中完成耗时操作,然后通过Handler发送消息,通知UI线程更新UI。
接着说下,Android异步更新UI的写法:
1、Handler
2.AsyncTask
3、runOnUiThread
4、RxJava
5、LiveData
6、Kotlin协程
2.概念
2.1程序、进程、CPU、内存关系
如上图,平时我们打包好一个应用,放在磁盘上,此时我们称之为程序或者应用,是静态的,比我我们熟悉的apk文件。
当我们执行程序(比如点击某个App),OS 会将它加载进内存,CPU 从内存某个起始地址开始读取指令并执行程序。
程序从磁盘上加载到内存并被CPU运行期间,称之为进程。因此我们通常说某个应用是否还在存活,实际上说的是进程是否还在内存里;也会说某某程序CPU占用率太高,实际上说的是进程的CPU占用率。
而操作系统负责管理磁盘、内存、CPU等交互,可以说是大管家。
2.2 进程和线程之间的关系
现在我们用简单的图片展示一下他们之间的关系
上图是单核CPU的情况
2.3 线程和协程之间的关系
2.4 kotlin 协程的优势
1、协程是轻量级线程、比线程耗费资源少 这话虽然是官方说的,但我觉得有点误导的作用,协程是语言层面的东西,线程是系统层面的东西,两者没有可比性。 协程就是一段代码块,既然是代码那就离不开CPU的执行,而CPU调度的基本单位是线程。
2、协程是线程框架 协程解决了移步编程时过多回调的问题,既然是异步编程,那势必涉及到不同的线程。Kotlin 协程内部自己维护了线程池,与Java 线程池相比有些优化的地方。在使用协程过程中,无需关注线程的切换细节,只需指定想要执行的线程即可,从对线程的封装这方面来说这说话也没问题。
3、协程效率高于线程 与第一点类似,协程在运行方面的高效率其实换成回调方式也是能够达成同样的效果,实际上协程内部也是通过回调实现的,只是在编译阶段封装了回调的细节而已。因此,协程与线程没有可比性。
3.kotlin协程初认识
在Kotlin中,协程就是线程的封装,它提供了一套标准的API来帮助我们编写并发任务。
在java中实现多任务并发
//线程
new Thread(new Runnable() {
@Override
public void run() {
//耗时的工作
}
}).start();
//线程池
ExecutorService executor = Executors.newFixedThreadPool(3);
executor.execute(new Runnable() {
@Override
public void run() {
//耗时的工作
}
});
android中实现多任务并发
在Android中,除了可以通过Java的方式,创建线程、使用线程池实现多任务并发之外,还可以AsyncTask等方式来实现多个耗时任务的并发执行:
//AsyncTask
public abstract class AsyncTask<Params, Progress, Result> {
//线程池中执行,执行耗时任务
protected abstract Result doInBackground(Params... params);
//UI线程中执行,后台任务进度有变化则执行该方法
protected void onProgressUpdate(Progress... values) {}
//UI线程执行,耗时任务执行完成后,该方法会被调用,result是任务的返回值
protected void onPostExecute(Result result) {}
}
无论是Java还是Android提供的组件,都可以实现多任务并发的执行,但是上面的组件都或多或少存在一些问题:
- 耗时任务执行结束后,子线程要将结果传递回主线程,两者之间的通信不太方便。
AsyncTask处理的回调方法比较多,当有多个任务时可能会出现回调嵌套。
使用协程实现多任务并发
我们还是使用AsyncTask举例
AsyncTask<String, Integer, String> task = new AsyncTask<String, Integer, String>() {
@Override
protected String doInBackground(String... strings) {
String userId = getUserId(); //获取userId
return userId;
}
@Override
protected void onPostExecute(final String userId) {
AsyncTask<String, Integer, String> task1 = new AsyncTask<String, Integer, String>() {
@Override
protected String doInBackground(String... strings) {
String name = getUserName(userId); //获取userName,需要用到userId
return name;
}
@Override
protected void onPostExecute(String name) {
textView.setText(name); //设置到TextView控件中
}
};
task1.execute(); //假设task1是一个耗时任务,去获取userName
}
};
task.execute(); //假设task是一个耗时任务,去获取userId
如果使用kotlin协程,上面的代码可以简化如下:
GlobalScope.launch(Dispatchers.Main) {
val userId = getUserId() //耗时任务,这里会切换到子线程
val userName = getUserName(userId) //耗时任务,这里会切换到子线程
textView.text = userName //设置到TextView控件中,切换到主线程
}
suspend fun getUserId(): String = withContext(Dispatchers.IO) {
//耗时操作,返回userId
}
suspend fun getUserName(userId: String): String = withContext(Dispatchers.IO) {
//耗时操作,返回userName
}
上面launch函数的{}的逻辑,就是一个协程。
相比于AsyncTask的写法,我们可以看到使用kotlin协程有以下好处:
- 协程将耗时任务和UI更新放在了上下三行处理,消除了
AsyncTask的回调嵌套,使用起来更加方便、简洁。 - 协程通过挂起与恢复,将耗时任务的结果直接返回给调用方,使得主线程能直接使用子线程的结果,UI更新更加方便
那么协程到底是怎么来简化异步代码的呢?下面从协程最经典的使用场景来切入 ---线程控制
callback
在Android中,如果要处理异步任务,最常见的就是使用callback
public interface Callback<T> {
void onSucceed(T result);
void onFailed(int errCode, String errMsg);
}
callback的特点很明显
- 优势:使用简单
- 缺点:如果业务多,很容易陷入回调地狱,嵌套逻辑复杂,维护成很高
RxJava
那么有什么方法能够解决呢?这时候很自然想到大名鼎鼎的RxJava
- 优势:RxJava使用链式调用,实现线程切换,消除回调
- 劣势:RxJava上手难度较大,而且各种操作符,很容易滥用,复杂度较高
而协程作为Kotlin自身的拓展库,使用更简单,更方便
下面使用协程来进行网络请求
launch {
val result = get("https://developer.android.com")
print(result)
}
suspend fun get(url: String) = withContext(Dispatchers.IO) {
//network request
}
这里展示了代码片段, launch并不是顶层函数,我们先不关注,只关注{}内的具体逻辑
通常做网络请求,都是使用callback,回调结果后处理,而上面的两行代码,分别执行在两个线程里,但是看起来和单线程一样。
这里的get("https://developer.android.com")就是一个挂起函数,能保证请求结束后,才开始打印结果,这就是协程中最核心的非阻塞式挂起。
日常开发中遇到的情况
我们通过 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("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(GitHubServiceApi::class.java)
}
那么我们请求网络时我们一般这样子调用:
gitHubServiceApi.getUser("bennyhuo").enqueue(object : Callback<User> {
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 线程来展示结果。这类代码大量存在于我们的逻辑当中,它有什么问题呢?
- 通过 Lambda 表达式,我们让线程切换变得不是那么明显,但它仍然存在,一旦开发者出现遗漏,这里就会出现问题
- 回调嵌套了两层,看上去倒也没什么,但真实的开发环境中逻辑一定比这个复杂的多,例如登录失败的重试
- 重复或者分散的异常处理逻辑,在请求失败时我们调用了一次
showError,在数据读取失败时我们又调用了一次,真实的开发环境中可能会有更多的重复
Kotlin 本身的语法已经让这段代码看上去好很多了,如果用 Java 写的话,你的直觉都会告诉你:你在写 Bug。
改造成协程
你当然可以改造成 RxJava 的风格,但 RxJava 比协程抽象多了,因为除非你熟练使用那些 operator,不然你根本不知道它在干嘛(试想一下 retryWhen)。协程就不一样了,毕竟编译器加持,它可以很简洁的表达出代码的逻辑,不要想它背后的实现逻辑,它的运行结果就是你直觉告诉你的那样。
对于 Retrofit,改造成协程的写法,有两种,分别是通过 CallAdapter 和 suspend 函数。
CallAdapter 的方式
我们先来看看 CallAdapter 的方式,这个方式的本质是让接口的方法返回一个协程的 Job:
interface GitHubServiceApi {
@GET("users/{login}")
fun getUser(@Path("login") login: String): Deferred<User>
}
那么我们需要为 Retrofit 添加对 Deferred 的支持,这需要用到开源库:
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
构造 Retrofit 实例时添加:
val gitHubServiceApi by lazy {
val retrofit = retrofit2.Retrofit.Builder()
.baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create())
//添加对 Deferred 的支持
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
retrofit.create(GitHubServiceApi::class.java)
}
那么这时候我们发起请求就可以这么写了:
GlobalScope.launch(Dispatchers.Main) {
try {
showUser(gitHubServiceApi.getUser("chenl").await())
} catch (e: Exception) {
showError(e)
}
}
首先我们通过 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> {
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 的回调函数调用后执行
showUser(gitHubServiceApi.getUser("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) {
gitHubServiceApi.getUser("bennyhuo").await(object: Continuation<User>{
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{ continuation.resume(user) }
} catch(e: Exception) {
handler.post{ continuation.resumeWithException(e) }
}
}
这样的回调大家一看就能明白。讲了这么多,请大家记住一点:从执行机制上来讲,协程跟回调没有什么本质的区别。
suspend 函数的方式
suspend 函数是 Kotlin 编译器对协程支持的唯一的黑魔法(表面上的,还有其他的我们后面讲原理的时候再说)了,我们前面已经通过 Deferred 的 await 方法对它有了个大概的了解,我们再来看看 Retrofit 当中它还可以怎么用。
首先我们修改接口方法:
@GET("users/{login}")
suspend fun getUser(@Path("login") login: String): User
这种情况 Retrofit 会根据接口方法的声明来构造 Continuation,并且在内部封装了 Call 的异步请求(使用 enqueue),进而得到 User 实例,具体原理后面我们有机会再介绍。使用方法如下:
GlobalScope.launch {
try {
showUser(gitHubServiceApi.getUser("chenl"))
} catch (e: Exception) {
showError(e)
}
}
它的执行流程与 Deferred.await 类似,我们就不再详细分析了。
4.kotlin协程的使用
4.1 引入kotlin协程相关依赖
在模块的build.gradle中加入以下依赖:
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
}
4.2 使用协程
4.2.1 kotlin协程的创建
Kotlin提供了三种方式来创建协程,如下所示:
//方式一
runBlocking { //runBlocking是一个顶级函数
...
}
//方式二
GlobalScope.launch { //GlobalScope是一个单例对象,直接使用launch开启协程
...
}
//方式三
val coroutineScope = CoroutineScope(context) //使用CoroutineContext创建CoroutineScope对象,通过launch开启协程
coroutineScope.launch {
...
}
-
方式一:它是线程阻塞的,它通常被用在单元测试和main函数中,平时的开发中我们一般不会用到它。
-
方式二:与方式一相比,它不会阻塞线程,但是它的生命周期和应用是一致的,而且无法做到取消(后面会讲到),所以也不推荐使用。
-
方式三:通过
CoroutineContext来创建一个CoroutineScope对象,通过CoroutineScope.launch或CoroutineScope.async可以开启协程,通过CoroutineContext也可以控制协程的生命周期。在开发过程中,一般推荐使用这种方式开启协程。
4.2.2 kotlin协程的挂起和恢复
什么是挂起函数?
挂起函数用于在协程中抽离与封装代码块,使用suspend关键字修饰普通函数可将其转化为挂起函数。挂起函数只能在其它挂起函数或者协程中调用。
Kotlin协程非常巧妙的将协程的挂起与恢复与挂起函数结合到了一起,调用挂起函数时意味着主调用协程将被”挂起“,挂起函数返回结果时意味着主调用协程被”恢复“。
比如下面的函数:
private suspend fun f() {}
具体是怎么挂起和恢复的
name: String,
onResult: (User) -> Unit,
onError: (Throwable) -> Unit) {
thread {
try {
// do something
handler.post { onResult(user) }
} catch(e: Throwable) {
handler.post { onError(e) }
}
}
}
getUser("zhangsan", { user -> setUser(user) }, { err -> log.e(err) })
上面的代码我们已经很熟悉了,当我们调用getUser后,该方法创建了一个新的线程开始执行异步任务,紧接着getUser就返回了,对于后续的setUser,loge等它们就处于一个被挂起的状态,需要等到异步任务执行完成后再恢复执行。
5.kotlin协程的基本概念
5.1 协程创建
创建协程有三种方式:launch、async、runBlocking
launch
launch 方法如下:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
//省略
return coroutine
}
launch 是 CoroutineScope 的扩展方法,需要 3 个参数。第一个参数,看字面意思是协程上下文,后边会重点讲到。第二个参数是协程启动模式,默认情况下,协程是创建后立即执行的。第三个参数,官方文档说这个 block 就是协程代码块,所以是必传的。返回的是一个 Job,这个 Job 可以理解为一个后台工作,在 block 代码块执行完成后会结束,也可以通过 Job 的 cancel 方法取消它。
async
async 方法如下:
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
//省略
return coroutine
}
同样也是 CoroutineScope 的扩展方法,参数跟 launch 是一模一样的,只是返回参数变成了 Deferred,这个 Deferred 继承于 Job,相当于一个带返回结果的 Job,返回结果可以通过调用它的 await 方法获取。
runBlocking
runBlocking 会阻塞调用他的线程,直到代码块执行完毕。
Log.i("zx", "当前线程1-" + Thread.currentThread().name)
runBlocking(Dispatchers.IO) {
delay(2000)
Log.i("zx", "休眠2000毫秒后,当前线程" + Thread.currentThread().name)
}
Log.i("zx", "当前线程2-" + Thread.currentThread().name)
输出内容
当前线程1-main
休眠2000毫秒后,当前线程DefaultDispatcher-worker-1
当前线程2-main
可以看到,即使协程指定了运行在 IO 线程,依旧会阻塞主线程。runBlocking 主要用来写测试代码,平常不要随意用,所以不再过多介绍。
5.2 CoroutineScope(协程作用域)
launch 和 async 都是 CoroutineScope 的扩展函数,CoroutineScope 又是什么呢,字面意思翻译过来是协程作用域,协程作用域类似于变量作用域,定义了协程代码的作用范围。作用域取消时,作用域中的协程都会被取消。 比如如下代码:
MainScope().launch {
var i = 0
launch(Dispatchers.IO) {
while (true) {
Log.i("chenl", "子协程正在运行着$i")
delay(1000)
}
}
while (true) {
i++
Log.i("chenl", "父协程正在运行着$i")
if (i>4) {
cancel()
}
delay(1000)
}
}
输出:
父协程正在运行着1
子协程正在运行着1
父协程正在运行着2
子协程正在运行着2
父协程正在运行着3
子协程正在运行着3
父协程正在运行着4
子协程正在运行着4
子协程正在运行着4
父协程正在运行着5
5 秒后,父协程调用 cancel()结束了,子协程也就结束了,并没有继续打印出值。
可以通过 CoroutineScope()来创建协程作用域,这并不是一个构造函数,CoroutineScope 是一个接口,所以没有构造函数,只是函数名与接口名同名而已,源码如下:
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
源码可见,创建 CoroutineScope 时需要传入 CoroutineContext,这个 CoroutineContext 也是 CoroutineScope 接口中唯一的成员变量。CoroutineScope.kt 这个文件中使用 CoroutineScope()创建了两个 Scope,一个是 MainScope,一个是 GlobalScope。源码如下:
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
public object GlobalScope : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}
MainScope 是一个方法,返回了一个运行在主线程的作用域,需要手动取消。GlobalScope 是一个全局作用域,整个应用程序生命周期他都在运行,不能提前取消,所以一般不会使用这个作用域。Android 中,ktx 库提供了一些常用的作用域供我们使用,如 lifecycleScope 和 viewModelScope。在 LifecycleOwner 的所有实现类中,如 Activity 和 Fragment 中都可以直接使用 lifecycleScope,lifecycleScope 会跟随 Activity 或 Fragment 的生命周期,在 Activity 或 Fragment 销毁时,自动取消协程作用域中的所有协程,不用手动管理,不存在内存泄露风险。类似的 viewModelScope 也会随着 viewModel 的销毁而取消。
目前已经有好几个地方出现了 CoroutineContext:启动协程时 launch 或者 async 方法需要 CoroutineContext,创建协程作用域时需要 CoroutineContext,协程作用域中有且只有一个成员变量也是 CoroutineContext,如下源码所示:
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
5.3 CoroutineContext (协程上下文)
CoroutineContext 保存了协程的上下文,是一些元素的集合(实际并不是用集合 Set 去存储),集合中每一个元素都有一个唯一的 key。通俗来讲,CoroutineContext 保存了协程所依赖的各种设置,比如调度器、名称、异常处理器等等。 CoroutineContext 源码如下:
public interface CoroutineContext {
public operator fun <E : Element> get(key: Key<E>): E?
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else
context.fold(this) { acc, element ->
//省略
}
public fun minusKey(key: Key<*>): CoroutineContext
public interface Key<E : Element>
public interface Element : CoroutineContext {
//省略
}
}
CoroutineContext 里有一个接口 Element,这个 Element 就是组成 CoroutineContext 的元素,最重要的是 plus 操作符函数,这个函数可以把几个 Element 合并成为一个 CoroutineContext,由于是操作符函数,所以可以直接用+调用。比如:
var ctx = Dispatchers.IO + Job() + CoroutineName("测试名称")
Log.i("chenl", ctx.toString())
输出
[JobImpl{Active}@31226a0, CoroutineName(测试名称), Dispatchers.IO]
共有哪几种元素呢?来看看 Element 的子类吧。Element 有这么几个子类(子接口):Job、CoroutineDispatcher、CoroutineName、CoroutineExceptionHandler。
CoroutineContext 由 Job、CoroutineDispatcher、CoroutineName、CoroutineExceptionHandler 组成。Job 可以控制协程的生命周期,也决定了子项异常时,父Job会不会取消。CoroutineDispatcher决定了协程运行在哪个线程。CoroutineName给协程起名字,用于调试时区分。CoroutineExceptionHandler 用于全作用域捕获并处理异常。子协程会自动继承父协程的CoroutineContext,并可以覆盖。CoroutineContext元素之间可以通过 + 运算符组合,也可以通过对应的key检索出CoroutineContext中的元素。
5.4 Job(作业)
Job 可以简单理解为一个协程的引用,创建协程后会返回 Job 实例,可以通过 Job 来管理协程的生命周期。Job 是 CoroutineContext 元素的一种,可以传入 CoroutineScope 用来使协程有不同的特性。主要关注Job()、SupervisorJob()这两个创建 Job 的函数以及Deferred这个 Job 的子接口。
Job()
创建一个处于活动状态的 Job 对象,可以传入父 Job,这样当父 Job 取消时就可以取消该 Job 以及他的子项。 该 Job 的任何子项失败都会立即导致该 Job 失败,并取消其其余子项。这个很好理解,例如:
CoroutineScope(Dispatchers.IO + Job()+MyExceptionHandler()).launch {
var index = 0
launch {
while (true) {
index++
if (index > 3) {
throw Exception("子协程1异常了")
}
Log.i("chen", "子协程1正在运行")
}
}
launch {
while (true) {
Log.i("chen", "子协程2正在运行")
}
}
}
子协程 1 异常了,就会导致整个 Job 失败,子协程 2 也不会继续运行。
SupervisorJob()
创建一个处于活动状态的 Job 对象。 该 Job 的子项之间彼此独立,互不影响,子项的失败或取消不会导致主 Job 失败,也不会影响其他子项。
CoroutineScope(Dispatchers.IO + SupervisorJob() + MyExceptionHandler()).launch {
launch {
while (true) {
index++
if (index > 3) {
throw Exception("子协程1异常了")
}
Log.i("chen", "子协程1正在运行")
}
}
launch {
while (true) {
Log.i("chen", "子协程2正在运行")
}
}
}
同样的代码,把 Job()换成 SupervisorJob()后,可以发现子协程 2 会一直运行,并不会因为子协程 1 异常而被取消。
我们常见的 MainScope、viewModelScope、lifecycleScope 都是用 SupervisorJob()创建的,所以这些作用域中的子协程异常不会导致根协程退出。 kotlin 提供了一个快捷函数创建一个使用 SupervisorJob 的协程,那就是 supervisorScope。例如:
CoroutineScope(Dispatchers.IO).launch {
supervisorScope {
//这里的子协程代码异常不会导致父协程退出。
}
}
等同于
CoroutineScope(Dispatchers.IO).launch {
launch(SupervisorJob()) {
}
}
是 Job 的子接口,是一个带有返回结果的 Job。async 函数创建的协程会返回一个 Deferred,可以通过 Deferred 的await()获取实际的返回值。async 与 await 类似于其他语言(例如 JavaScript)中的 async 与 await,通常用来使两个协程并行执行。 例如如下代码
suspend fun testAsync1(): String = withContext(Dispatchers.Default)
{
delay(2000)
"123"
}
suspend fun testAsync2(): String = withContext(Dispatchers.Default)
{
delay(2000)
"456"
}
lifecycleScope.launch {
val time1 = Date()
val result1 = testAsync1()
val result2 = testAsync2()
Log.i("chen", "结果为${result1 + result2}")
Log.i("chen", "耗时${Date().time - time1.time}")
}
会输出:
结果为123456
耗时5034
如果改为使用 async,让两个协程并行。代码如下:
lifecycleScope.launch {
val time1 = Date()
val result1 = async { testAsync1() }
val result2 = async { testAsync2() }
Log.i("chenl", "结果为${result1.await() + result2.await()}")
Log.i("chenl", "耗时${Date().time - time1.time}")
}
输出
结果为123456
耗时3023
总耗时为两个并行协程中耗时较长的那个时间。
Job生命周期
既然Job是来管理协程的,那么它提供了六种状态来表示协程的运行状态。
New: 创建Active: 运行Completing: 已经完成等待自身的子协程Completed: 完成Cancelling: 正在进行取消或者失败Cancelled: 取消或失败
这六种状态Job对外暴露了三种状态,它们随时可以通过Job来获取
public val isActive: Boolean
public val isCompleted: Boolean
public val isCancelled: Boolean
如果协程处于活跃状态,协程运行出错或者调用 job.cancel() 都会将当前任务置为取消中 (Cancelling) 状态 (isActive = false, isCancelled = true)。当所有的子协程都完成后,协程会进入已取消 (Cancelled) 状态,此时 isCompleted = true。
5.5 CoroutineDispatcher 协程调度器
指定了协程运行的线程或线程池,共有 4 种。
- Dispatchers.Main 运行在主线程,Android 平台就是 UI 线程,是单线程的。
- Dispatchers.Default 默认的调度器,如果上下文中未指定调度器,那么就是 Default。适合用来执行消耗 CPU 资源的计算密集型任务。它由 JVM 上的共享线程池支持。 默认情况下,此调度器使用的最大并行线程数等于 CPU 内核数,但至少为两个。
- Dispatchers.IO IO 调度器,使用按需创建的线程共享池,适合用来执行 IO 密集型阻塞操作,比如 http 请求。此调度器默认并行线程数为内核数和 64 这两个值中的较大者。
- Dispatchers.Unconfined 不限于任何特定线程的协程调度器,不常用。
需要注意的是 Default 和 IO 都是运行在线程池中,两个子协程有可能是在一个线程中,有可能不是一个线程中。例如如下代码:
CoroutineScope(Dispatchers.IO).launch {
launch {
delay(3000)
Log.i("chenl", "当前线程1-" + Thread.currentThread().name)
}
launch {
Log.i("chenl", "当前线程2-" + Thread.currentThread().name)
}
}
输出
当前线程2-DefaultDispatcher-worker-2
当前线程1-DefaultDispatcher-worker-5
5.6 CoroutineName 协程名称
传入一个 String 作为协程名称,一般用于调试时日志输出,以区分不同的调度器。
5.7 CoroutineExceptionHandler 异常处理器
用于处理协程作用域内所有未捕获的异常。实现 CoroutineExceptionHandler 接口就好了,代码如下:
class MyExceptionHandler : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler
override fun handleException(context: CoroutineContext, exception: Throwable) {
Log.i("chenl", "${context[CoroutineName]}中发生异常,${exception.message}")
}
}
然后用+拼接并设置给作用域。
CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
launch(CoroutineName("子协程1") + MyExceptionHandler()) {
throw Exception("异常")
}
}
输出内容为
CoroutineName(父协程)中发生异常,异常
不对呀,明明是子协程 1 抛出的异常,为什么输出的是父协程抛出的异常呢?原来,异常规则就是子协程会将异常一级一级向上抛,直到根协程。那什么是根协程呢?跟协程简单来讲就是最外层协程,还有一个特殊的规则就是,使用 SupervisorJob 创建的协程也视为根协程。比如如下代码:
CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
launch(CoroutineName("子协程1") + MyExceptionHandler() + SupervisorJob()) {
throw Exception("异常")
}
}
输出内容为
CoroutineName(子协程1)中发生异常,异常
说起处理异常,大家肯定想到 try / catch,为什么有了 try / catch,协程里还要有一个 CoroutineExceptionHandler 呢?或者说 CoroutineExceptionHandler 到底起什么作用,什么时候用 CoroutineExceptionHandler 什么时候用 try / catch 呢?官方文档是这么描述 CoroutineExceptionHandler 的用于处理未捕获的异常,是用于全局“全部捕获”行为的最后一种机制。 你无法从CoroutineExceptionHandler的异常中恢复。 当调用处理程序时,协程已经完成。,这段文字描述的很清楚了,这是全局(这个全局是指根协程作用域全局)的异常捕获,是最后的一道防线,此时协程已经结束,你只能处理异常,而不能做其他的操作。举个例子吧
CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
val test = 5 / 0
Log.i("chenl", "即使异常了,我也想继续执行协程代码,比如:我要通知用户,让用户刷新界面")
}
协程体中第一行 5/0 会抛出异常,会在 CoroutineExceptionHandler 中进行处理,但是协程就会直接结束,后续的代码不会再执行,如果想继续执行协程,比如弹出 Toast 通知用户,这里就做不到了。换成 try / catch 肯定就没有问题了。
CoroutineScope(Dispatchers.IO + CoroutineName("父协程") + MyExceptionHandler()).launch {
try {
val test = 5 / 0
} catch (e: Exception) {
Log.i("chenl", "我异常了")
}
Log.i("chenl", "继续执行协程的其他代码")
}
那既然如此,我直接把协程中所有代码都放在 try / catch 里,不用 CoroutineExceptionHandler 不就行了?听起来好像没毛病,那我们就试试吧
inline fun AppCompatActivity.myLaunch(
crossinline block: suspend CoroutineScope.() -> Unit
) {
CoroutineScope(Dispatchers.IO).launch {
try {
block()
} catch (e: Exception) {
Log.e("chen", "异常了," + e.message)
}
}
}
做了一个封装,只要是调用封装的 myLaunch 函数,那所有的协程代码都被 try / catch 包着,这肯定没问题了吧。比如我这样调用
myLaunch {
val test = 5 / 0
}
程序没崩,很好。换个代码继续调用
myLaunch {
launch {
val test = 5 / 0
}
}
APP 崩了,不对呀,这里最外层明明已经包了一层 try / catch,怎么捕获不到呢?想一下之前协程抛异常的规则:子协程会将异常一级一级向上抛,直到根协程。这里用 launch 又新创建了一个子协程,异常代码运行在子协程中,子协程直接把异常抛给了父协程,所以 try / catch 捕获不到。这里父协程又没有指定异常处理器,所以就崩了。有人可能要抬杠了,那我直接在子协程里 try / catch 不就不会崩了?确实不会崩了,这里你记住了加try / catch,那别的地方会不会忘了加呢。所以 CoroutineExceptionHandler 全作用域捕获异常的优势就出来了。所以简单总结一下二者的区别和使用场景吧。
- CoroutineExceptionHandler 以协程为作用域全局捕获未处理异常,可以捕获子协程的异常,捕获到异常时,协程就已经结束了。适用于做最后的异常处理以保证不崩溃,比如用来记录日志等。
- try / catch 可以更加精细的捕获异常,精确到一行代码或者一个操作,无法捕获子协程的异常,不会提前结束协程。适用于捕获可以预知的异常。
以下是个人的心得,不一定正确,仅供参考。
CoroutineExceptionHandler 适用于捕获无法预知的异常。try / catch 适用于可以预知的异常。 什么是可以预知的异常和不可预知的异常呢?举个例子:你要往磁盘写文件,可能会没有权限,也可能磁盘写满了,这些异常都是可以预知的,此时应该用 try / catch。不可预知的异常就是指,代码看起来没毛病,但我不知道哪里会不会出错,不知道 try / catch 该往哪里加,try / catch 有没有少加,这个时候就该交给 CoroutineExceptionHandler,毕竟 CoroutineExceptionHandler 是最后一道防线。
5.8 CoroutineStart 启动模式
上边讲了 launch 和 async 的第二个参数就是 CoroutineStart,也就是协程的启动模式,共分为如下 4 种:
-
DEFAULT-默认模式,立即调度协程;
-
LAZY-仅在需要时才懒惰地启动协程,使用
start()启动; -
ATOMIC-原子地(以不可取消的方式)调度协程,执行到挂起点之后可以被取消;
-
UNDISPATCHED-同样是原子地(以不可取消的方式)执行协程到第一个挂起点。与ATOMIC的区别是:UNDISPATCHED不需要调度,直接执行的,而ATOMIC是需要调度后再执行的;UNDISPATCHED是在父协程指定的线程中执行,到达挂起点之后会切到自己上下文中指定的线程,ATOMIC是在自己的协程上下文中指定的线程执行。
需要注意的是调度(schedules)和执行(executes)是不一样的,调度之后并不一定是立即执行。
分别举例说明:
LAZY 模式:
val job = lifecycleScope.launch(start = CoroutineStart.LAZY) {
Log.i("chen", "协程运行了1")
}
上边的代码,并不会打印出内容,需要手动调用job.start(),才能启动协程并打印出内容。
ATOMIC 模式:
val job = lifecycleScope.launch(start = CoroutineStart.ATOMIC) {
Log.i("chen", "协程运行了1")
delay(2000)
Log.i("chen", "协程运行了2")
}
job.cancel()
由于使用的 ATOMIC 启动模式,执行到挂起点之前(delay 是挂起函数)是不能被取消的,所以无论如何都会打印出 "协程运行了 1"。执行到挂起点之后可以被取消,所以不会打印出第二行。
UNDISPATCHED 模式:
lifecycleScope.launch {
Log.i("chen", "父协程,当前线程" + Thread.currentThread().name)
val job = launch(Dispatchers.IO, CoroutineStart.UNDISPATCHED) {
Log.i("chen", "子协程,当前线程" + Thread.currentThread().name)
delay(1000)
Log.i("chen", "子协程delay后,当前线程" + Thread.currentThread().name)
}
}
上述代码输出
父协程,当前线程main
子协程,当前线程main
子协程delay后,当前线程DefaultDispatcher-worker-1
结果验证了,在到达第一个挂起点之前,都是使用父协程所在线程去执行协程,到达挂起点之后才会使用自己 coroutineContext 中设置的线程。类似于 ATOMIC ,在到达第一个挂起点之前同样是不可取消的。
5.9 suspend 与 withContext
前边反复提到挂起点,那什么是挂起点呢?什么又是挂起呢?挂起点实际上就是协程代码执行到 suspend 函数时的点,此时协程会暂停,suspend 函数之后的代码不会再执行,等到 suspend 函数执行完之后,协程代码会自动继续执行。上边用到的 delay 函数就是一个挂起函数,他会暂停(suspend)当前协程代码块,先执行 delay 函数,等 delay 执行完后继续执行原有的代码。先暂停,等代码执行完了在再自动恢复(resume)执行这个特性非常适合处理异步任务。例如如下代码:
private suspend fun getBitmapByHttp(): Bitmap {
Log.i("chen", "当前线程" + Thread.currentThread().name)
val url = URL("https://****/result.png");
val imageConnection = url.openConnection() as HttpURLConnection
imageConnection.requestMethod = "GET"
imageConnection.connect()
val inputStream: InputStream = imageConnection.inputStream
return BitmapFactory.decodeStream(inputStream)
}
lifecycleScope.launch {
val bitmap = getBitmapByHttp()//第一个行
viewBinding.imageView.setImageBitmap(bitmap)//第二行
}
先定义了一个 suspend 函数,这个函数从网络加载图片获取到 bitmap。然后启动一个 lifecycleScope 的协程,在里边调用这个 suspend 函数。应该如我们所想,第一行是个 suspend 函数,是个挂起点,会等到 getBitmapByHttp 执行完再继续执行第二行 setImageBitmap。然而运行起来之后,先是输出 "当前线程 main" 然后应用崩了,抛出了 NetworkOnMainThreadException 异常,为什么这里的 suspend 函数会运行在主线程呢?因为 suspend 并不知道具体要切到哪个线程,所以依旧运行在主线程。并且上述代码,Android Studio 会提示 Redundant 'suspend' modifier(多于的 suspend 修饰符)。如何让 suspend 函数切换到具体的线程呢?这就要用到 withContext 了。
public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T
这是 withContext 的签名,可以看到 withContext 必须要传入协程上下文以及一个协程代码块。协程上下文中包含了 Dispatchers,它指定了 withContext 将要切到哪个线程中去执行。withContext 也是一个 suspend 挂起函数,所以 withContext 执行时,调用它的协程会先暂停,等到它切到指定的线程并执行完之后,会自动再切回到调用它的协程,并继续执行协程代码。这其实就是挂起,自动切走,执行完了再自动切回来继续之前的操作。同样是之前的代码,加上 withContext 之后就没问题了。
private suspend fun getBitmapByHttp(): Bitmap = withContext(Dispatchers.IO) {
Log.i("chen", "当前线程" + Thread.currentThread().name)
val url = URL("https://****/result.png");
val imageConnection = url.openConnection() as HttpURLConnection
imageConnection.requestMethod = "GET"
imageConnection.connect()
val inputStream: InputStream = imageConnection.inputStream
BitmapFactory.decodeStream(inputStream)
}
lifecycleScope.launch {
val bitmap = getBitmapByHttp()
viewBinding.imageView.setImageBitmap(bitmap)
}
既然 withContext 可以切走再切回来,那调用时不要最外层的 lifecycleScope.launch {},不启动协程可以吗。试了一下发现 AS 提示错误,编译都过不了,提示"Suspend function 'getBitmapByHttp' should be called only from a coroutine or another suspend function",意思是挂起函数只能在另一个挂起函数或者协程里调用,那另一个挂起函数也只能在另另一个挂起函数或者协程里调用,如此套娃,最终就是挂起函数只能在一个协程里调用,这么限制是因为暂停、切走、切回去并恢复执行这些操作是由协程框架完成的,如果不在协程里运行,这些是没法实现的。
如果某个函数比较耗时,我们就可以将其定义为挂起函数,用 withContext 切换到非 UI 线程去执行,这样就不会阻塞 UI 线程。上边的例子也展示了自定义一个挂起函数的过程,那就是给函数加上 suspend 关键字,然后用 withContext 等系统自带挂起函数将函数内容包起来。
试想一下,如果不用 suspend 和 withContext,那我们就需要自己写开启 Thread,并自己用 Handler 去实现线程间通信。有了协程之后,这些都不需要我们考虑了,一下简单了很多,更重要的是,这样不会破坏代码的逻辑结构,两行代码之间就像普通阻塞式代码一样,但是却实现了异步非阻塞式的效果,这也就是非阻塞式的含义
总结:
-
挂起
就是一个切走再自动切回来继续执行的线程调度操作,这个操作由协程提供,所以限制了suspend方法只能在协程里调用。 -
withContext
就是协程提供的一个挂起函数,起到的就是切到指定线程执行代码块,执行完再切回来的作用。 -
suspend
仅仅只是一个限制,限制了挂起函数只能在协程中调用,并没有实际的切线程 -
非阻塞式
写法像普通阻塞式代码一样,却实现了非阻塞式的效果,没有回调也没有嵌套,不破坏代码逻辑结构 -
自定义挂起函数
给函数加上suspend关键字并用withContext等系统自带挂起函数将函数内容包起来
6.kotlin协程实战
7.到底什么是协程
作为android开发者我们都是异步代码的“受害者”,大家肯定遇到过“回调地狱”,它让你的代码可读性急剧降低;也写过大量复杂的异步逻辑处理、异常处理,这让你的代码重复逻辑增加;因为回调的存在,还得经常处理线程切换,这似乎并不是一件难事,但随着代码体量的增加,它会让你抓狂,线上上报的异常因线程使用不当导致的可不在少数。
而协程可以帮你优雅的处理掉这些。
简单来说就是,协程是一种非抢占式或者说协作式的计算机程序并发调度的实现,程序可以主动挂起或者恢复执行。这里还是需要有点儿操作系统的知识的,我们在 Java 虚拟机上所认识到的线程大多数的实现是映射到内核的线程的,也就是说线程当中的代码逻辑在线程抢到 CPU 的时间片的时候才可以执行,否则就得歇着,当然这对于我们开发者来说是透明的;而经常听到所谓的协程更轻量的意思是,协程并不会映射成内核线程或者其他这么重的资源,它的调度在用户态就可以搞定,任务之间的调度并非抢占式,而是协作式的。
如果大家熟悉 Java 虚拟机的话,就想象一下 Thread 这个类到底是什么吧,为什么它的 run 方法会运行在另一个线程当中呢?谁负责执行这段代码的呢?显然,咋一看,Thread 其实是一个对象而已,run 方法里面包含了要执行的代码——仅此而已。协程也是如此,如果你只是看标准库的 API,那么就太抽象了,但我们开篇交代了,学习协程不要上来去接触标准库,kotlinx.coroutines 框架才是我们用户应该关心的,而这个框架里面对应于 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 或者函数),也都包含了这段代码的运行状态。
而真正调度时二者才有了本质的差异,具体怎么调度,我们只需要知道调度结果就能很好的使用它们了。
总结
kotlin协程就是 Kotlin 提供的一套线程封装的 API,但并不是说协程就是为线程而生的。 协程设计的初衷是为了解决并发问题,让「协作式多任务」 实现起来更加方便。但初学Kotlin协程可以从线程控制切入,也就是本文介绍的内容,至于更高级的应用和实现原理,后续会继续分享出来。