KotlinKotlinAndroid

Android 上的协程(第一部分):背景介绍

2021-10-18  本文已影响0人  两三行代码

协程要解决的问题是什么?

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之外,协程还添加了supendresume

这个功能是 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                       |
+-----------------------------------+

继续上面的例子,让我们使用调度器来定义 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 数据解析这样的耗时任务;

  1. 简化长时间运行任务的代码,例如从网络、磁盘读取,甚至解析大型 JSON 结果。
  2. 执行精确的 main-safety 以确保您永远不会意外阻塞主线程,而不会使代码难以阅读和编写。

引用自Coroutines on Android (part I): Getting the background

上一篇下一篇

猜你喜欢

热点阅读