这是「怎样在 Android 上使用协程」的系列文章的第一篇。
这篇内容关注协程怎么工作的以及它们解决什么问题。
协程解决什么问题
Kotlin 的协程采用了一种新的并发方式(a new style of concurrency),可以在 Android 上简化异步代码。
虽然在 Kotlin 1.3 协程作为全新特性出现的,但是协程的概念从编程语言诞生之初就已经存在了。第一个探索使用协程的语言是的 Simula ,出现在 1967年。
最近几年,协程越来越受欢迎,现在许多流行的编程语言里都有协程,如 Javascript、C#、Python、Ruby等等。Kotlin 协程设计基于这些构建过大型应用的经验。
在 Android 上,协程可以非常好的解决两个问题:
- 防止耗时任务在主线程运行过久,阻塞主线程
- 从主线程上安全地去调用网络或磁盘操作
让我们深入这两个问题,看看协程是如何帮助我们写出更简洁的代码。
耗时任务
访问网页或和 API 交互都需要访问网络。同样的,访问数据库或从硬盘加载图片都需要读取文件。这些就是我说的耗时任务——这些任务耗时太长,导致你的应用卡顿。
很难想象,现代手机执行代码相比网络请求有多快。在 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
来启动一个新的协程。
挂起配合上恢复代替回调
Suspend and resume work together to replace callbacks.
在上面的例子中,get
会在启动网络请求之前被 挂起 。然后get
函数会脱离主线程继续负责运行网络请求。然后,当网络请求完成时,它不用回调来通知主线程,而是简单的 恢复 挂起的协程。
![显示 Kotlin 如何实现 挂起 和 恢复来替换回调的动画。](Coroutines on Android (part I) Getting the background.assets/16abe1535c453a63.gif)
查看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 在一个后台线程挂起。值的一提的说,协程将在主线程运行。(注:原文 It’s worth saying clearly and often that coroutines will run on the main thread. )实际上,在响应一个 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
允许你控制在什么线程上执行任何代码块,而不需要引入回来返回结果,所以你可以将它应用于非常小的函数,比如从数据库读取数据或执行网络请求。因此,一个好的实践是使用 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)所遵循的模式。如果你在整个代码库中都遵循这种风格,那么你的代码将会简单的多,并避免将线程问题和应用程序逻辑混合在一起。当遵循这个模式时,协程可以在主线程上自由调用,用简单的代码请求网络或数据库,同时保证用户不会看到卡顿。
withContext 的性能
对于提供主线程安全上,withContext
使用回调或者 RxJava 一样快。在某些情况下,withContext
可能通过优化甚至比回调性能还好。如果一个函数将要访问10次数据库,你可以告诉 Kotlin 使用 withContext
在 10 次的调用的外部切换一次(原文:If a function will make 10 calls to a database, you can tell Kotlin to switch once in an outer withContext
around all 10 calls. )。然后,即使数据库会重复调用 withContext
,它也会保持在同一个调度器上,并遵循快速路径。此外在 Default 调度器和 IO 调度器直接切换经过优化会尽可能的避免线程切换。
下篇是什么
在这篇文章中,我们探讨了协程最擅长解决的问题。协程在编程语言中是一个存在很久的概念,由于它能够简化与网络交互的代码,所以最近变得非常流行。
在 Android 上,你可以使用它们解决两个非常常见的问题:
- 简化耗时任务的代码,比如从网络、磁盘读取数据,甚至解析大型 JSON 结果。
- 执行精确的主线程安全,以确保不会意外阻塞主线程,而不会使代码难以读和写(原文:Performing precise main-safety to ensure that you never accidentally block the main thread without making code difficult to read and write)。
在下一篇文章,我们探索它们是如何配合 Android 的,以便你使用它(原文:In the next post we’ll explore how they fit in on Android to keep track of all the work you started from a screen! Give it a read:)。