(译) 协程在 Android 上的应用 (第一部分): 了解背景

原文链接

这是关于在Android上使用协程的一系列文章的一部分。这篇文章重点介绍了协程如何工作以及它们解决了哪些问题。

协程解决了哪些问题

Kotlin 协程引入了一种新的并发风格,可以在 Android 上用于简化异步代码。虽然它们是Kotlin 1.3 版中的新特性,但自编程语言诞生以来,协程的概念就已存在。Simula于1967年首次使用协程。

在过去几年中,协程越来越受欢迎,现在已经包含在许多流行的编程语言中,例如 Javascript,C#,Python,Ruby 和 Go 等等。 Kotlin 协程基于已用于构建大型应用程序的既定概念。

在 Android 上,协程是解决两个问题的绝佳解决方案:

  1. 长时间运行的任务,是那些花费太长时间以至于阻塞主线程的任务
  2. 主安全性允许你确保可以从主线程调用所有挂起方法。

让我们深入了解每一个,看看协程如何帮助我们以更整洁的方式构建代码!

长时间运行的任务

获取网页或与API交互都涉及发出网络请求。同样,从数据库读取或从磁盘加载图像涉及读取文件。这些东西就是我称之为长时间运行的任务 - 任务花费太长时间让你的应用程序要停下来等待它们!

与网络请求相比,现代手机执行代码的速度可能很难理解。在 Pixel 2 上,单个 CPU 周期仅需0.0000004秒,这个数字在对人来说可能没什么概念。但是,如果你将网络请求视为一眨眼,大约400毫秒(0.4秒),则更容易理解 CPU 的运行速度。在一眨眼间,或者网络请求有点慢,CPU可以执行超过一百万次循环!

在 Android 上,每个应用程序都有一个主线程,负责处理UI(如绘制视图)和协调用户交互。如果此线程上发生了太多工作,则应用程序似乎挂起或减速,从而导致不良用户体验。任何长时间运行的任务都应该在不阻塞主线程的情况下完成,因此你的应用程序不会表现为所谓的“jank”,如卡顿的动画,或者对触摸事件响应缓慢。

为了从主线程执行网络请求,常见的模式是回调。回调提供了一个库的句柄,它可以用来在将来某个时候回调你的代码。使用回调,抓取 developer.android.com 可能看起来像这样:

1
2
3
4
5
6
7
class ViewModel: ViewModel() {
fun fetchDocs() {
get("developer.android.com") { result ->
show(result)
}
}
}

即使从主线程调用 get ,它也将使用另一个线程来执行网络请求。然后,一旦从网络获得结果,回调将在主线程上被调用。这是处理长时间运行任务的好方法,而像 Retrofit 这样的库可以帮助你在不阻塞主线程的情况下发出网络请求。

使用协程执行长时间运行的任务

协程是一种简化用于管理长期运行任务(如fetchDocs)的代码的方法。为了探索协程如何使长时间运行的任务的代码更简单,让我们重写上面的回调示例以使用协同程序。

1
2
3
4
5
6
7
8
9
// 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 提供了一种执行此代码的方法,并且永远不会阻塞主线程。

协程通过添加两个新操作来构建常规功能。除了 invokereturn 之外,协程还添加了 suspendresume

  • suspend - 暂停当前协同程序的执行,保存所有局部变量
  • resume - 从暂停的地方继续暂停协同程序

Kotlin 通过函数上的 suspend 关键字添加此功能。你只能从其他挂起函数调用挂起函数,或者使用像 launch 这样的协程 builder 来启动新的协程。

suspend 和 resume 通力合作取代了回调。

在上面的示例中,get将在启动网络请求之前挂起协程。函数get仍将负责从主线程运行网络请求。然后,当网络请求完成时,它可以简单地恢复它挂起的协程,而不是调用回调来通知主线程。


展示kotlin如何实现 suspend 和 resume 来替换回调。

查看 fetchDocs 的执行方式,你可以看到 suspend 的工作原理。每当协程挂起时,都会复制并保存当前堆栈帧( Kotlin 用于跟踪正在运行的函数及其变量的位置)以供日后使用。恢复后,堆栈帧将从保存位置复制回来并再次开始运行。在动画的中间 - 当主线程上的所有协程都被挂起时,主线程可以自由更新屏幕并处理用户事件。suspendresume 通力合作就能替换回调。很简约!

当主线程上的所有协程都被挂起时,主线程可以自由地做其他工作。

即使我们编写了看起来完全像阻塞网络请求的简单顺序代码,协程也会按照我们想要的方式运行我们的代码并避免阻塞主线程!

接下来,让我们来看看如何使用协程来实现主线程安全并探索调度器(dispatchers)。

协程的主线程安全

在Kotlin协程中,编写良好的挂起函数总是可以安全地从主线程调用。无论它们做什么,它们都应该允许任何线程调用它们。

但是,我们在 Android 应用程序中做了很多事情,这些事情在主线程上发生得太慢了。网络请求,解析JSON,从数据库读取或写入,甚至只是迭代大型列表。其中任何一个都有可能运行缓慢,导致用户可见的“jank”的任务应该在主线程之外运行。

使用 suspend 并不能告诉 Kotlin 在后台线程上运行函数。值得一提的是,并且通常协程将在主线程上运行。事实上,在响应UI事件时启动协程时使用Dispatchers.Main.immediate 是一个非常好的主意 - 这样,如果你没有做需要主线程安全的长期运行任务,结果就可以在用户的下一帧中可用。

协程将在主线程上运行,suspend 并不意味着后台线程。

要编写一个对主线程主安全来说太慢的函数,你可以告诉 Kotlin 协程在Default或IO调度程序上执行工作。在 Kotlin 中,所有协程必须在 dispatcher 中运行 - 即使它们在主线程上运行。协程可以自行挂起,dispatcher 知道如何恢复它们。

为了指定协程应该运行的位置,Kotlin 提供了三个可用于线程调度的调度器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
+-----------------------------------+
| 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 将自动提供主线程安全性。

与Kotlin协程一起使用时,Retrofit 和 Volley 等网络库管理自己的线程,并且在代码中不需要明确的主线程安全性。

要继续上面的示例,让我们使用调度器来定义get函数。在你的函数体内部调用withContext(Dispatchers.IO) 来创建一个将在IO调度器上运行的块。放在该块中的任何代码将始终在 IO 调度器上执行。由于 withContext 本身是一个 suspend 函数,因此它将使用协程来提供主线程的安全性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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

使用协程,你可以在很好的细粒度上控制进行线程调度。因为 withContext 允许你控制任何代码行执行的线程而不引入回调以返回结果,所以你·可以将它应用于非常小的函数,例如从数据库读取或执行网络请求。所以一个好的做法是使用 withContext 来确保在包括Main在内的任何 Dispatcher 上调用每个函数都是安全的 - 这样调用者就不必考虑执行该函数所需的线程。

在此示例中,fetchDocs正在主线程上执行,但可以安全地调用get,后者在后台执行网络请求。因为协程支持挂起和恢复,所以只要 withContext 块完成,主线程上的协程就会恢复结果。

编写良好的挂起函数总是可以安全地从主线程调用。

让每个挂起功能都安全可靠是一个非常好的主意。如果它做任何触及磁盘,网络或甚至只是使用太多 CPU 的东西,请使用withContext使其从主线程调用安全。这是基于coroutines的库,如 Retrofit 和 Room 所遵循的。如果你在整个代码库中遵循此样式,则代码将更加简单,并避免将线程问题与应用程序逻辑混合在一起。一致地遵循协程,协程可以在主线程上自由启动,并使用简单的代码发出网络或数据库请求,同时保证用户不会看到“jank”。

withContext 的性能

在提供主线程安全的时候,withContext 与回调或 RxJava 一样快。在某些情况下,可以在回调的范围之外使用 withContext 进行优化。如果一个函数将对数据库进行10次调用,则可以告诉 Kotlin 在所有10个调用的外部 withContext 中切换一次。然后,即使数据库库将重复调用 withContext ,它仍将保留在同一个调度器中并遵循快速路径。此外,Dispatchers.DefaultDispatchers.IO 之间的切换已经过优化,以尽可能避免线程切换。

下一步是什么

在这篇文章中,我们探讨了协程在解决问题方面遇到的问题。协程是编程语言中一个非常古老的概念,由于它们能够使与网络交互的代码更简单,因此最近变得流行。

在 Android 上,你可以使用它们来解决两个非常常见的问题:

  1. 简化长时间运行任务的代码,例如从网络,磁盘读取,甚至解析巨大的JSON结果。
  2. 执行精确的主线程安全性,以确保你不会在不使难以读写代码的情况下意外阻塞主线程。
非典型前端coder wechat
想要随时Follow我的最新博客,可扫码关注我的公众号