Android 上的协程(第一部分):背景介绍
协程要解决的问题是什么?
Kotlin 协程引入了一种新的并发风格,可用于 Android 以简化异步代码。 虽然它们是 Kotlin 1.3 中的新手,但自编程语言出现以来,协程的概念就一直存在。 使用协程探索的第一种语言是 1967 年的 Simula。
在过去几年中,协程越来越受欢迎,现在已经包含在许多流行的编程语言中,例如 Javascript、C#、Python、Ruby 和 Go 等等。 Kotlin 协程基于已用于构建大型应用程序的既定概念。
在 Android 上,协程可以很好地解决两个问题:
- 长时间运行的任务:需要很长时间执行并会阻塞主线程的任务。
- 主线程安全 :允许您确保可以从主线程调用任何挂起函数。
让我们深入探讨一下协程如何帮助我们以更简洁的方式构建代码!
长时间运行的任务
获取网页或与 API 交互都涉及发出网络请求。 类似地,从数据库读取或从磁盘加载图像涉及读取文件。 这类事情就是我所说的长时间运行的任务——需要很长时间以至于让你的应用停止并等待它们完成!
与网络请求相比,很难理解现代手机执行代码的速度有多快。 在 Pixel 2 上,单个 CPU 周期只需要不到 0.0000000004 秒,这个数字用人类的术语很难掌握。 但是,如果您将网络请求视为眨眼之间,大约 400 毫秒(0.4 秒),则更容易理解 CPU 的运行速度。 一眨眼,或者有点慢的网络请求,CPU 可以执行超过 10 亿个周期!
在 Android 上,每个应用程序都有一个主线程,负责处理 UI(如绘制视图)和协调用户交互。 如果此线程上发生的工作过多,应用程序似乎会挂起或变慢,从而导致不良的用户体验。 任何长时间运行的任务都应该在不阻塞主线程的情况下完成,这样你的应用程序就不会显示所谓的“卡顿”,比如冻结动画,或者对触摸事件响应缓慢。
为了从主线程执行网络请求,一个常见的模式是回调。 回调提供了一个库的句柄,它可以在将来的某个时间回调到你的代码中。 通过回调,获取 developer.android.com 可能如下所示:
class ViewModel: ViewModel() {
fun fetchDocs() {
get("developer.android.com") { result ->
show(result)
}
}
}
即使从主线程调用 get,它也会使用另一个线程来执行网络请求。 然后,一旦从网络获得结果,就会在主线程上调用回调。 这是处理长时间运行任务的好方法,像 Retrofit 这样的库可以帮助您在不阻塞主线程的情况下进行网络请求。
将协程用于长时间运行的任务
协程是一种简化用于管理 fetchDocs 等长时间运行任务的代码的方法。 为了探索协程如何使长时间运行的任务的代码更简单,让我们重写上面的回调示例以使用协程。
// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.Main
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(或call)和return之外,协程还添加了supend和resume。
- suspend — 暂停当前协程的执行,保存所有局部变量
- resume — 从暂停的地方继续暂停的协程
这个功能是 Kotlin 通过函数上的 suspend 关键字添加的。 您只能从其他挂起函数调用挂起函数,或者使用像 launch 这样的协程构建器来启动新的协程。
Suspend和resume一起工作以替换回调
在上面的例子中,get 将在启动网络请求之前挂起协程。 函数 get 仍将负责从主线程运行网络请求。 然后,当网络请求完成时,它可以简单地恢复它暂停的协程,而不是调用回调来通知主线程。
动画展示了 Kotlin 如何实现挂起和恢复来替换回调
当主线程上的所有协程都被挂起时,主线程可以自由地做其他工作
即使我们编写了看起来完全像阻塞网络请求的简单的顺序代码,协程也会按照我们想要的方式运行我们的代码,并避免阻塞主线程!
接下来,让我们看看如何使用协程实现主线程安全(main-safety)并探索调度程序(dispatchers)。
协程的主线程安全性
在 Kotlin 协程中,编写良好的挂起函数始终可以安全地从主线程调用。 无论他们做什么,他们都应该始终允许任何线程调用它们。
但是,我们在 Android 应用程序中做的很多事情都太慢了,无法在主线程上发生。 网络请求、解析 JSON、从数据库读取或写入,甚至只是迭代大型列表。 其中任何一个都有可能运行得足够慢,导致用户可见的“卡顿”,并且不应该从主线程运行。
使用 suspend 不会告诉 Kotlin 在后台线程上运行一个函数。 值得明确且经常地说,协程将在主线程上运行。 事实上,在启动协程以响应 UI 事件时使用 Dispatchers.Main.immediate 是一个非常好的主意——这样,如果你最终没有执行需要 main-safety 的长时间运行的任务,结果可以 在下一帧中可供用户使用。
协程会在主线程上运行,挂起不代表后台
如果需要处理一个函数,且这个函数在主线程上执行太耗时,但是又要保证这个函数是主线程安全的,那么您可以让 Kotlin 协程在 Default 或 IO 调度器上执行工作。 在 Kotlin 中,所有协程都必须在调度程序中运行——即使它们在主线程上运行。 协程可以自行挂起,调度程序知道如何恢复它们。
为了指定协程应该在哪里运行,Kotlin 提供了三个可用于线程调度的 Dispatcher。
+-----------------------------------+
| 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.Main
withContext(Dispatchers.IO) {
// Dispatchers.IO
/* perform blocking network IO here */
}
// Dispatchers.Main
使用协程,您可以进行细粒度控制的线程调度。 因为 withContext 使您可以控制任何代码行在哪个线程上执行,而无需引入回调来返回结果,所以您可以将其应用于非常小的功能,例如从数据库读取或执行网络请求。 因此,一个好的做法是使用 withContext 来确保在任何 Dispatcher 上调用每个函数(包括 Main)都是安全的——这样调用者就不必考虑需要哪个线程来执行该函数。
在这个例子中,fetchDocs 在主线程上执行,但可以安全地调用 get 在后台执行网络请求。 因为协程支持挂起和恢复,一旦 withContext 块完成,主线程上的协程就会恢复并且获取结果。
从主线程(或主线程安全)调用编写良好的挂起函数总是安全的
让每一个挂起函数都是 main-safe 是一个非常好的主意。 如果它做了任何涉及磁盘、网络的事情,甚至只是使用了过多的 CPU,请使用 withContext 使其安全地从主线程调用。 这是基于协程的库(如 Retrofit 和 Room)遵循的模式。 如果您在整个代码库中都遵循这种风格,您的代码将会简单得多,并且可以避免将线程问题与应用程序逻辑混合在一起。 如果始终如一地遵循,协程可以在主线程上自由启动,并使用简单的代码发出网络或数据库请求,同时保证用户不会看到“卡顿”。
withContext 的性能
withContext 同回调或者是提供主线程安全特性的 RxJava 相比的话,性能是差不多的。在某些情况下,甚至还可以优化 withContext 调用,让它的性能超越基于回调的等效实现。如果某个函数需要对数据库进行 10 次调用,您可以使用外部 withContext 来让 Kotlin 只切换一次线程。这样一来,即使数据库的代码库会不断调用 withContext,它也会留在同一调度器并跟随快速路径,以此来保证性能。此外,在 Dispatchers.Default 和 Dispatchers.IO 中进行切换也得到了优化,以尽可能避免了线程切换所带来的性能损失。
下一步
本篇文章介绍了使用协程来解决什么样的问题。协程是一个计算机编程语言领域比较古老的概念,但因为它们能够让网络请求的代码比较简洁,从而又开始流行起来。
在 Android 平台上,您可以使用协程来处理两个常见问题:
简化处理类似于网络请求、磁盘读取甚至是较大 JSON 数据解析这样的耗时任务;
- 简化长时间运行任务的代码,例如从网络、磁盘读取,甚至解析大型 JSON 结果。
- 执行精确的 main-safety 以确保您永远不会意外阻塞主线程,而不会使代码难以阅读和编写。