在Android应用程序中使用Kotlin协程

633 阅读29分钟

原英文样例:Use Kotlin Coroutines in your Android App

1. 在你开始之前

在此 Codelab 中,您将学习如何在 Android 应用程序中使用 Kotlin 协程——这是管理后台线程的推荐方式,可通过减少回调需求来简化代码。协程是 Kotlin 的一项功能,可将长时间运行的任务(例如数据库或网络访问)的异步回调转换为顺序执行代码。

这是一个代码片段,可让您了解将要做什么。

// Async callbacks
networkRequest { result ->
   // Successful network request
   databaseSave(result) { rows ->
     // Result saved
   }
}

基于回调的代码将使用协程转换为顺序执行代码。

// The same code with coroutines
val result = networkRequest()
// Successful network request
databaseSave(result)
// Result saved

您将从使用架构组件构建的现有应用程序开始,该应用程序对长时间运行的任务使用回调样式。

到本 Codelab 结束时,您将有足够的经验在您的应用程序中使用协程从网络加载数据,并且您将能够将协程集成到应用程序中。 您还将熟悉协程的最佳实践,以及如何针对使用协程的代码编写测试。

先决条件

  • 熟悉架构组件 ViewModelLiveDataRepositoryRoom
  • 熟悉 Kotlin 语法,包括扩展函数和 lambda。
  • 基本理解 Android 上使用线程,包括主线程、后台线程、回调。

你会做什么

  • 调用用协程编写的代码并获取结果。
  • 使用挂起函数使异步代码有序。
  • 使用 launchrunBlocking 来控制代码的执行方式。
  • 学习使用 suspendCoroutine 将现有 API 转换为协程的技术。
  • 将协程与架构组件一起使用。
  • 学习测试协程的最佳实践。

你需要什么

Android Studio 4.1(Codelab 可能适用于其他版本,但有些东西可能会丢失或看起来不同)。

2. 开始设置

下载代码

单击以下链接下载此 Codelab 的所有代码:

Download Zip

...或使用以下命令从命令行克隆 GitHub 存储库:

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

kotlin-coroutines 存储库包含两个 Codelab 的代码。 本 codelab 使用 coroutines-codelab 目录中的项目。 该项目中有两个应用程序模块:

  • image.png start — 使用 Android 架构组件的简单应用程序,您将向其中添加协程
  • image.pngfinished_code — 已经添加了协程的项目

3. 运行启动示例应用程序

首先,让我们看看起始示例应用程序的样子。 按照这些说明在 Android Studio 中打开示例应用程序。

  1. 如果您下载了 kotlin-coroutines zip 文件,请解压缩该文件。
  2. 在 Android Studio 中打开 coroutines-codelab 项目。
  3. 选择启动应用程序模块。
  4. 单击 image.png Run 按钮,然后选择一个模拟器或连接您的 Android 设备,该设备必须能够运行 Android Lollipop(支持的最低 SDK 为 21)。 Kotlin Coroutines 屏幕应该会出现:

image.png

如果您看到“Android framework is detected. Click to configure”错误消息,请确保您打开的是 coroutines-codelab 目录而不是父目录。

此入门应用程序使用线程在您按下屏幕后短暂延迟增加计数。 它还将从网络中获取新标题并将其显示在屏幕上。 现在尝试一下,您应该会在短暂延迟后看到计数和消息发生变化。 在此 Codelab 中,您将转换此应用程序以使用协程。

此应用程序使用架构组件将 MainActivity 中的 UI 代码与 MainViewModel 中的应用程序逻辑分开。 花点时间熟悉一下项目的结构。

image.png

  1. MainActivity 显示 UI,注册点击监听器,可以显示一个 Snackbar。 它将事件传递给 MainViewModel 并根据 MainViewModel 中的 LiveData 更新屏幕。
  2. MainViewModel 处理 onMainViewClicked 中的事件,并将使用 LiveDataMainActivity 通信。
  3. Executors 定义了 BACKGROUND,它可以在后台线程上运行东西。
  4. TitleRepository 从网络获取结果并将它们保存到数据库中。

添加协程到工程

要在 Kotlin 中使用协程,您必须在项目的 build.gradle(模块:app)文件中包含 coroutines-core 库。 Codelab 项目已为您完成此操作,因此您无需执行此操作即可完成 Codelab。

Android 上的协程可用作核心库,以及 Android 特定的扩展:

  • kotlinx-coroutines-core — 在 Kotlin 中使用协程的主接口
  • kotlinx-coroutines-android — 支持协程中的 Android 主线程

starter app 已经在 build.gradle 中包含了依赖。 创建新的 app 项目时,需要打开 build.gradle (模块: app) 并将协程依赖添加到项目中

dependencies {
  ...
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x"
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x"
}

你可以在 Kotlin Coroutines 发布页面上找到 Coroutines 库的最新版本号来代替“x.x.x”。

协程和 RxJava

如果您在当前代码库中使用 RxJava,则可以使用 kotlin-coroutines-rx 库与协程集成。

4. Kotlin 中的协程

在 Android 上,避免阻塞主线程是必不可少的。主线程是一个处理所有 UI 更新的线程。它也是调用所有点击处理程序和其他 UI 回调的线程。因此,它必须平稳运行才能保证出色的用户体验。

为了让您的应用程序在没有任何可见暂停的情况下向用户显示,主线程必须每 16 毫秒或更长时间更新一次屏幕,大约每秒 60 帧。许多常见任务花费的时间比这更长,例如解析大型 JSON 数据集、将数据写入数据库或从网络获取数据。因此,从主线程调用这样的代码会导致应用程序暂停、卡顿甚至冻结。如果您阻塞主线程的时间过长,应用程序甚至可能会崩溃并显示应用程序无响应Application Not Responding)对话框。

回调模式(The callback pattern)

在不阻塞主线程的情况下执行长时间运行的任务的一种模式是回调。通过使用回调,您可以在后台线程上启动长时间运行的任务。当任务完成时,将调用回调以通知您主线程上的结果。

看一个回调模式的例子。

// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
    // The slow network request runs on another thread
    slowFetch { result ->
        // When the result is ready, this callback will get the result
        show(result)
    }
    // makeNetworkRequest() exits after calling slowFetch without waiting for the result
}

因为这段代码是用 @UiThread 注释的,所以它必须运行得足够快才能在主线程上执行。这意味着,它需要非常快地返回,以便下次屏幕更新不会延迟。但是,由于 slowFetch 需要几秒钟甚至几分钟才能完成,因此主线程无法等待结果。show(result) 回调允许 slowFetch 在后台线程上运行并在它准备好时返回结果。

使用协程移除回调

回调是一种很好的模式,但它们也有一些缺点。大量使用回调的代码会变得难以阅读和推理。此外,回调不允许使用某些语言功能,例如异常。

Kotlin 协程可让您将基于回调的代码转换为顺序代码。按顺序编写的代码通常更易于阅读,甚至可以使用异常等语言功能。

最后,它们做完全相同的事情:等待从长时间运行的任务获得结果并继续执行。然而,在代码中,它们看起来非常不同。

关键字 suspend 是 Kotlin 用于标记可用于协程的函数或函数类型的方式。当协程调用标记为 suspend 的函数时,它不会像普通函数调用那样阻塞直到该函数返回,而是 挂起(suspends)执行,直到结果准备好,然后从结果中断处继续执行。当它挂起等待结果时,它会解除阻塞它正在运行的线程,以便其他函数或协程可以运行。

例如在下面的代码中,makeNetworkRequest()slowFetch() 都是挂起函数。

// Slow request with coroutines
@UiThread
suspend fun makeNetworkRequest() {
    // slowFetch is another suspend function so instead of 
    // blocking the main thread  makeNetworkRequest will `suspend` until the result is 
    // ready
    val result = slowFetch()
    // continue to execute after the result is ready
    show(result)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }

就像回调版本一样,makeNetworkRequest 必须立即从主线程返回,因为它被标记为 @UiThread。这意味着通常它不能调用像 slowFetch 这样的阻塞方法。 这就是 suspend 关键字发挥其魔力的地方。

重要提示suspend 关键字不指定代码的运行线程。 挂起函数可以在后台线程或主线程上运行。

与基于回调的代码相比,协程代码用更少的代码完成解除当前线程阻塞的相同结果。由于它的顺序风格,很容易链接几个长时间运行的任务,而不需要创建多个回调。例如,从两个网络端点获取结果并将其保存到数据库的代码可以编写为协程中的函数,无需回调。像这样:

// Request data from network and save it to database with coroutines

// Because of the @WorkerThread, this function cannot be called on the
// main thread without causing an error.
@WorkerThread
suspend fun makeNetworkRequest() {
    // slowFetch and anotherFetch are suspend functions
    val slow = slowFetch()
    val another = anotherFetch()
    // save is a regular function and will block this thread
    database.save(slow, another)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
// anotherFetch is main-safe using coroutines
suspend fun anotherFetch(): AnotherResult { ... }

另一个名字的协程 其他语言的 asyncawait 模式是基于协程的。如果您熟悉此模式,则 suspend 关键字类似于 async。但是在 Kotlin 中, await() 在调用挂起函数时是隐式的。

Kotlin 有一个方法 Deferred.await() 用于等待从异步构建器启动的协程的结果。

5. 使用协程控制 UI

在本练习中,您将编写一个协程以在延迟后显示消息。首先,请确保您在 Android Studio 中打开了模块 start

了解 CoroutineScope

在 Kotlin 中,所有协程都在 CoroutineScope 内运行。作用域通过其 Job 控制协程的生命周期。当您取消作用域Scope)的 Job 时,它会取消在该作用域中启动的所有协程。在 Android 上,您可以使用作用域来取消所有正在运行的协程,例如,当用户导航离开 ActivityFragment 时。作用域还允许您指定默认调度程序(Dispatcher)。 调度程序控制哪个线程运行协程。对于由 UI 启动的协程,通常在 Dispatchers.Main 上启动它们是正确的,它是 Android 上的主线程。在 Dispatchers.Main 上启动的协程在挂起时不会阻塞主线程。由于 ViewModel 协程几乎总是在主线程上更新 UI,因此在主线程上启动协程可以节省额外的线程切换。在主线程上启动的协程可以在启动后随时切换调度程序。 例如,它可以使用另一个调度程序从主线程解析大型 JSON 结果。

协程提供主要安全性(main-safety)

因为协程可以方便地随时切换线程并将结果传回原线程,所以在主线程上启动 UI 相关的协程是个好主意。

RoomRetrofit 等库在使用协程时提供开箱即用的主安全性,因此您无需管理线程来进行网络或数据库调用。这通常会导致代码更加简单。

然而,像排序列表或读取文件这样的阻塞代码(blocking code)仍然需要显式代码来创建主要安全性,即使在使用协程时也是如此。如果您使用的网络或数据库库(还)不支持协程,这也是正确的。

使用 viewModelScope

AndroidX lifecycle-viewmodel-ktx 库向 ViewModels 添加了一个 CoroutineScope,它被配置为启动与ui相关的协程。要使用此库,您必须将其包含在项目的 build.gradle (模块: start) 文件中。 这一步已经在 codelab 项目中完成了。

dependencies {
  ...
  // replace x.x.x with latest version
  implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x"
}

该库添加了一个 viewModelScope 作为 ViewModel 类的扩展函数。这个作用域绑定到 Dispatchers.Main 并且会在 ViewModel 被清除时自动取消。

从线程切换到协程

MainViewModel.kt 中找到下一个 TODO 以及以下代码:

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   BACKGROUND.submit {
       Thread.sleep(1_000)
       _taps.postValue("$tapCount taps")
   }
}

此代码使用 BACKGROUND ExecutorService(在 util/Executor.kt 中定义)在后台线程中运行。由于 sleep 会阻塞当前线程,因此如果在主线程上调用它,它将冻结 UI。用户单击主视图一秒钟后,它请求一个 snackbar

您可以通过从代码中删除 BACKGROUND 并再次运行它来看到这种情况。加载状态中的 spinner 将不会显示,一秒钟后一切都会“跳”到最终状态。

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   Thread.sleep(1_000)
   _taps.postValue("$tapCount taps")
}

用这个基于协程的代码替换 updateTaps 做同样的事情。 你必须导入 launchdelay

MainViewModel.kt

/**
* Wait one second then display a snackbar.
*/
fun updateTaps() {
   // launch a coroutine in viewModelScope
   viewModelScope.launch {
       tapCount++
       // suspend this coroutine for one second
       delay(1_000)
       // resume in the main dispatcher
       // _snackbar.value can be called directly from main thread
       _taps.postValue("$tapCount taps")
   }
}

这段代码做同样的事情,在显示 snackbar 之前等待一秒钟。但是,有一些重要的区别:

  1. viewModelScope. launch 将在 viewModelScope 中启动一个协程。这意味着当我们传递给 viewModelScopeJob 被取消时,此Job/作用域的所有协程都将被取消。如果用户在 delay 返回之前离开 Activity,则在销毁 ViewModel 时调用 onCleared 时,此协程将自动取消。
  2. 由于 viewModelScope 具有默认调度程序 Dispatchers.Main,因此该协程将在主线程中启动。 稍后我们将看到如何使用不同的线程。
  3. 函数 delay 是一个挂起函数。这在 Android Studio 中由左侧装订线中的image.png图标显示。即使这个协程在主线程上运行,delay 也不会阻塞线程一秒钟。相反,调度程序将安排协程在一秒钟内在下一条语句中恢复。

继续运行它。 当您单击主视图时,一秒钟后您应该会看到一个 snackbar

6. 通过行为测试协程

在本练习中,您将为刚刚编写的代码编写一个测试。本练习向您展示如何使用 kotlinx-coroutines-test 库测试在 Dispatchers.Main 上运行的协程。在本 Codelab 的后面,您将实现一个直接与协程交互的测试。

本节中使用的 kotlinx-coroutines-test 库被标记为实验性的,在发布之前可能会有重大更改。

查看现有代码

打开 androidTest 文件夹中的 MainViewModelTest.kt

MainViewModelTest.kt

class MainViewModelTest {
   @get:Rule
   val coroutineScope =  MainCoroutineScopeRule()
   @get:Rule
   val instantTaskExecutorRule = InstantTaskExecutorRule()

   lateinit var subject: MainViewModel

   @Before
   fun setup() {
       subject = MainViewModel(
           TitleRepository(
                   MainNetworkFake("OK"),
                   TitleDaoFake("initial")
           ))
   }
}

规则是在 JUnit 中执行测试之前和之后运行代码的一种方式。两个规则用于允许我们在设备外测试中测试 MainViewModel

  1. InstantTaskExecutorRule 是一个 JUnit 规则,用于配置 LiveData 以同步执行每个任务
  2. MainCoroutineScopeRule 是此代码库中的自定义规则,用于配置 Dispatchers.Main 以使用来自 kotlinx-coroutines-testTestCoroutineDispatcher。这允许测试推进一个用于测试的虚拟时钟,并允许代码在单元测试中使用 Dispatchers.Main

setup 方法中,使用测试伪造创建了 MainViewModel 的一个新实例——这些是启动代码中提供的网络和数据库的伪造实现,以帮助在不使用真实网络或数据库的情况下编写测试。

对于这个测试,fakes 只需要满足 MainViewModel 的依赖。 在本代码实验室的后面,您将更新 fakes 以支持协程。

编写一个控制协程的测试

添加一个新的测试,确保点击在主视图被点击一秒钟后被更新:

MainViewModelTest.kt

@Test
fun whenMainClicked_updatesTaps() {
   subject.onMainViewClicked()
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
   coroutineScope.advanceTimeBy(1000)
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}

通过调用 onMainViewClicked,我们刚刚创建的协程将被启动。 此测试检查在调用 onMainViewClicked 后点击文本是否立即保持“0 点击”,然后在 1 秒后更新为“1 点击”。

该测试使用虚拟时间(virtual-time)来控制由 onMainViewClicked 启动的协程的执行。MainCoroutineScopeRule 允许您暂停、恢复或控制在 Dispatchers.Main 上启动的协程的执行。这里我们调用了 AdvanceTimeBy(1_000),这将导致主调度程序立即执行计划在 1 秒后恢复的协程。

此测试是完全确定性的,这意味着它将始终以相同的方式执行。而且,因为它可以完全控制在 Dispatchers.Main 上启动的协程的执行,所以它不必等待一秒钟来设置值。

运行现有测试

  1. 在编辑器中右键单击类名 MainViewModelTest 以打开上下文菜单。在上下文菜单中选择image.png RunMainViewModelTest
  2. 对于以后的运行,您可以在工具栏中 image.png 按钮旁边的配置中选择此测试配置。默认情况下,该配置将被称为 MainViewModelTest

您应该会看到测试通过! 运行时间应该不到一秒钟。

7. 从回调转向协程

在这一步中,您将开始转换存储库以使用协程。 为此,我们将向 ViewModelRepositoryRoomRetrofit 添加协程。

在我们切换到使用协程之前,了解架构的每个部分负责什么是一个好主意。

  1. MainDatabase 使用 Room 实现了一个数据库,用于保存和加载 Title
  2. MainNetwork 实现了一个获取新标题的网络 API。 它使用 Retrofit 来获取 TitleRetrofit 被配置为随机返回错误或模拟数据,但在其他方面表现得好像它正在发出真实的网络请求。
  3. TitleRepository 实现了一个 API,用于通过组合来自网络和数据库的数据来获取或刷新 Title
  4. MainViewModel 表示屏幕的状态并处理事件。 当用户点击屏幕时,它会告诉存储库刷新 Title

由于网络请求是由 UI 事件驱动的,我们希望基于它们启动协程,因此开始使用协程的自然位置是在 ViewModel 中。

回调版本

打开 MainViewModel.kt 可以看到 refreshTitle 的声明。

MainViewModel.kt

/**
* Update title text via this LiveData
*/
val title = repository.title


// ... other code ...


/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   // TODO: Convert refreshTitle to use coroutines
   _spinner.value = true
   repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
       override fun onCompleted() {
           _spinner.postValue(false)
       }

       override fun onError(cause: Throwable) {
           _snackBar.postValue(cause.message)
           _spinner.postValue(false)
       }
   })
}

每次用户点击屏幕时都会调用此函数 - 它会导致存储库刷新标题并将新标题写入数据库。

这个实现使用回调来做一些事情:

  • 在开始查询之前,它会显示一个带有 _spinner.value = true 的加载状态的 spinner
  • 当它得到结果时,它用 _spinner.value = false 清除加载状态的 spinner
  • 如果出现错误,它会告诉 snackbar 显示并清除加载状态的 spinner

请注意, onCompleted 回调未传递 Title 。 由于我们将所有 Title 写入 Room 数据库,因此 UI 通过观察 Room 更新的 LiveData 更新为当前 Title

在更新到协程中,我们将保持完全相同的行为。 使用像 Room 数据库这样的可观察数据源来自动使 UI 保持最新是一种很好的模式。

object: TitleRefreshCallback 是什么意思?

这是在 Kotlin 中构建匿名类的方法。 它创建了一个实现 TitleRefreshCallback 的新对象。

协程版本

让我们用协程重写 refreshTitle 吧!

由于我们马上就需要它,让我们在我们的存储库 (TitleRespository.kt) 中创建一个空的挂起函数。定义一个新函数,该函数使用 suspend 运算符告诉 Kotlin 它与协程一起工作。

TitleRepository.kt

suspend fun refreshTitle() {
    // TODO: Refresh from network and write to database
    delay(500)
}

完成此 Codelab 后,您将更新它以使用 RetrofitRoom 获取新 Title 并使用协程将其写入数据库。现在,它只会花 500 毫秒假装工作然后继续。

MainViewModel 中,将 refreshTitle 的回调版本替换为启动新协程的回调版本:

MainViewModel.kt

/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           repository.refreshTitle()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

让我们逐步完成这个函数:

viewModelScope.launch {

就像更新点击次数的协程一样,首先在 viewModelScope 中启动一个新的协程。这将使用 Dispatchers.Main,这是可以的。即使 refreshTitle 会发出网络请求和数据库查询,它也可以使用协程来公开主安全( main-safe)接口。 这意味着从主线程调用它是安全的。

因为我们使用的是 viewModelScope,当用户离开这个屏幕时,这个协程开始的工作将自动取消。 这意味着它不会发出额外的网络请求或数据库查询。

从非协程创建协程时,从 launch 开始。

这样,如果它们抛出未捕获的异常,它将自动传播到未捕获的异常处理程序(默认情况下会使应用程序崩溃)。以 async 启动的协程不会向其调用者抛出异常,直到您调用 await。 但是,您只能从协程内部调用 await,因为它是一个挂起函数。

进入协程后,您可以使用 launchasync 来启动子协程。当您没有返回结果时使用 launch ,当您返回结果时使用 async

接下来的几行代码实际上在 repository 中调用 refreshTitle

try {
    _spinner.value = true
    repository.refreshTitle()
}

在此协程执行任何操作之前,它会启动 loading spinner——然后它会像常规函数一样调用 refreshTitle。 但是,由于 refreshTitle 是一个挂起函数,它的执行方式与普通函数不同。

我们不必传递回调。 协程将挂起,直到它被 refreshTitle 恢复。虽然它看起来就像一个普通的阻塞函数调用,但它会自动等待网络和数据库查询完成,然后再继续而不阻塞主线程。

} catch (error: TitleRefreshError) {
    _snackBar.value = error.message
} finally {
    _spinner.value = false
}

挂起函数中的异常就像常规函数中的错误一样工作。 如果在挂起函数中抛出错误,它将被抛出给调用者。因此,即使它们的执行方式完全不同,您也可以使用常规的 try/catch 块来处理它们。 这很有用,因为它让您可以依靠内置语言支持进行错误处理,而不是为每个回调构建自定义错误处理。

而且,如果你从协程中抛出异常——默认情况下,该协程将取消它的父级。 这意味着很容易同时取消几个相关的任务。

然后,在 finally 块中,我们可以确保在查询运行后始终关闭 spinner

未捕获的异常会发生什么

协程中未捕获的异常类似于非协程代码中的未捕获异常。默认情况下,他们会取消协程的 Job,并通知父协程他们应该取消自己。如果没有协程处理异常,它最终将被传递给 CoroutineScope 上的一个未捕获的异常处理程序。

默认情况下,未捕获的异常将发送到 JVM 上线程的未捕获异常处理程序。 您可以通过提供 CoroutineExceptionHandler 来自定义此行为。

通过选择启动配置然后按 image.png 再次运行应用程序,当您点击任意位置时,您应该会看到一个 loading spinnerTitle 将保持不变,因为我们还没有连接我们的网络或数据库。

8. 从阻塞代码中创建主安全(main-safe)函数

在本练习中,您将学习如何切换协程运行的线程以实现 TitleRepository 的工作版本。

查看 refreshTitle 中现有的回调代码

打开 TitleRepository.kt 并查看现有的基于回调的实现。

TitleRepository.kt

// TitleRepository.kt

fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
   // This request will be run on a background thread by retrofit
   BACKGROUND.submit {
       try {
           // Make network request using a blocking call
           val result = network.fetchNextTitle().execute()
           if (result.isSuccessful) {
               // Save it to database
               titleDao.insertTitle(Title(result.body()!!))
               // Inform the caller the refresh is completed
               titleRefreshCallback.onCompleted()
           } else {
               // If it's not successful, inform the callback of the error
               titleRefreshCallback.onError(
                       TitleRefreshError("Unable to refresh title", null))
           }
       } catch (cause: Throwable) {
           // If anything throws an exception, inform the caller
           titleRefreshCallback.onError(
                   TitleRefreshError("Unable to refresh title", cause))
       }
   }
}

TitleRepository.kt 中,refreshTitleWithCallbacks 方法是通过回调实现的,以将加载和错误状态传达给调用者。

这个函数做了很多事情来实现刷新。

  1. 使用 BACKGROUND ExecutorService 切换到另一个线程
  2. 使用阻塞的 execute() 方法运行 fetchNextTitle 网络请求。 这将在当前线程中运行网络请求,在这种情况下是 BACKGROUND 线程之一。
  3. 如果结果成功,则使用 insertTitle 将其保存到数据库中并调用 onCompleted() 方法。
  4. 如果结果不成功,或者出现异常,调用 onError 方法告诉调用者刷新失败。

这个基于回调的实现是主安全(main-safe)的,因为它不会阻塞主线程。但是,当工作完成时,它必须使用回调来通知调用者。 它还调用它切换的 BACKGROUND 线程上的回调。

从协程调用阻塞调用

在不向网络或数据库引入协程的情况下,我们可以使用协程使此代码主安全(main-safe)。这将使我们摆脱回调并允许我们将结果传递回最初调用它的线程。

您可以在需要从协程内部执行阻塞或 CPU 密集型工作的任何时候使用此模式,例如排序和过滤大型列表或从磁盘读取。

此模式应用于与代码中的阻塞 API 集成或执行 CPU 密集型工作。 如果可能,最好使用 Room 或 Retrofit 等库中的常规挂起函数。

为了在任何调度程序之间切换,协程使用 withContext。调用 withContext 会切换到其他调度程序,仅用于 lambda,然后返回到使用该 lambda 的结果调用它的调度程序。

默认情况下,Kotlin 协程提供了三个 DispatcherMainIODefaultIO 调度程序针对 IO 工作进行了优化,例如从网络或磁盘读取,而 Default 调度程序针对 CPU 密集型任务进行了优化。

TitleRepository.kt

suspend fun refreshTitle() {
   // interact with *blocking* network and IO calls from a coroutine
   withContext(Dispatchers.IO) {
       val result = try {
           // Make network request using a blocking call
           network.fetchNextTitle().execute()
       } catch (cause: Throwable) {
           // If the network throws an exception, inform the caller
           throw TitleRefreshError("Unable to refresh title", cause)
       }
      
       if (result.isSuccessful) {
           // Save it to database
           titleDao.insertTitle(Title(result.body()!!))
       } else {
           // If it's not successful, inform the callback of the error
           throw TitleRefreshError("Unable to refresh title", null)
       }
   }
}

这个实现使用了对网络和数据库的阻塞调用——但它仍然比回调版本简单一点。

此代码仍然使用阻塞调用。调用 execute()insertTitle(...) 都会阻塞这个协程正在运行的线程。但是,通过使用 withContext 切换到 Dispatchers.IO,我们阻塞了 IO 调度程序中的线程之一。调用它的协程(可能在 Dispatchers.Main 上运行)将被挂起,直到 withContext lambda 完成。

与回调版本相比,有两个重要的区别:

  1. withContext 将结果返回给调用它的 Dispatcher,在本例中为 Dispatchers.Main。 回调版本在后台执行程序服务中调用线程上的回调。
  2. 调用者不必将回调传递给此函数。 他们可以依靠挂起和恢复来获得结果或错误。

高级提示

此代码不支持协程取消,但可以!协程取消是合作的。这意味着您的代码需要明确检查取消,每当您调用 kotlinx-coroutines 中的函数时都会发生这种情况。

因为这个 withContext 块只调用阻塞调用,所以它在从 withContext 返回之前不会被取消。

要解决此问题,您可以定期调用 yield 以让其他协程有机会运行并检查取消。在这里,您将在网络请求和数据库查询之间添加一个对 yield 的调用。 然后,如果在网络请求期间取消协程,则不会将结果保存到数据库中。

您还可以显式检查取消,这在制作低级协程接口时应该执行。

再次运行应用

如果您再次运行该应用程序,您将看到新的基于协程的实现正在从网络加载结果!

9. Room & Retrofit 中的协程

为了继续协程集成,我们将使用稳定版 RoomRetrofit 中对挂起函数的支持,然后通过使用挂起函数来大幅简化我们刚刚编写的代码。

Room 中的协程

首先打开 MainDatabase.kt 并使 insertTitle 成为挂起函数:

MainDatabase.kt

// add the suspend modifier to the existing insertTitle

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

当您这样做时,Room 将使您的查询主安全(main-safe)并自动在后台线程上执行它。 但是,这也意味着您只能从协程内部调用此查询。

而且 - 这就是在 Room 中使用协程所需要做的全部工作。 很漂亮。

Retrofit 中的协程

接下来让我们看看如何将协程与 Retrofit 集成。 打开 MainNetwork.kt 并将 fetchNextTitle 更改为挂起功能。 还将返回类型从 Call<String> 更改为 String

挂起功能支持需要 Retrofit 2.6.0 或更高版本。

MainNetwork.kt

// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}

要在 Retrofit 中使用挂起函数,您必须做两件事:

  1. 向函数添加 suspend 修饰符
  2. 从返回类型中删除 Call 包装器。 这里我们返回 String,但您也可以返回复杂的 json-backed 类型。如果您仍想提供对 Retrofit 完整结果的访问,您可以从挂起函数返回 Result<String> 而不是 String

Retrofit 将自动使挂起函数成为 主安全(main-safe),因此您可以直接从 Dispatchers.Main 调用它们。

RoomRetrofit 都使暂停功能成为主安全(main-safe)的。

Dispatchers.Main 调用这些挂起函数是安全的,即使它们从网络获取并写入数据库。

RoomRetrofit 都使用自定义调度程序,不使用 Dispatchers.IO

Room 将使用配置的默认查询事务执行器运行协程。

Retrofit 将在后台创建一个新的 Call 对象,并在其上调用 enqueue 以异步发送请求。

使用 RoomRetrofit

现在 RoomRetrofit 支持挂起功能,我们可以从我们的存储库中使用它们。 打开 TitleRepository.kt,看看使用挂起函数如何大大简化逻辑,甚至与阻塞版本相比:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

哇,短了很多。 发生了什么? 事实证明,依赖挂起和恢复可以让代码更短。 Retrofit 允许我们在此处使用 StringUser 对象等返回类型,而不是 Call。这是安全的,因为在挂起函数内部,Retrofit 能够在后台线程上运行网络请求并在调用完成时恢复协程。

更好的是,我们摆脱了 withContext。 由于 RoomRetrofit 都提供了主安全(main-safe)挂起函数,所以从 Dispatchers.Main 编排这个异步工作是安全的。

您不需要使用 withContext 来调用主安全(main-safe)挂起函数。

按照惯例,您应该确保在您的应用程序中编写的挂起函数是主安全(main-safe)的。 这样就可以安全地从任何调度程序调用它们,甚至是 Dispatchers.Main

修复编译器错误

转移到协程确实涉及更改函数的签名,因为您不能从常规函数调用挂起函数。当您在此步骤中添加 suspend 修饰符时,会生成一些编译器错误,显示如果您在实际项目中将函数更改为挂起会发生什么。

检查项目并通过将函数更改为 suspend 创建来修复编译器错误。 以下是每个问题的快速解决方案:

TestingFakes.kt

更新测试 fakes 以支持新的挂起修饰符。

TitleDaoFake

  • 按 alt-enter(Mac 上的 option-enter)将挂起修饰符添加到层次结构中的所有函数

MainNetworkFake

  1. 按 alt-enter 将挂起修饰符添加到层次结构中的所有函数
  2. 用这个函数替换 fetchNextTitle
override suspend fun fetchNextTitle() = result

MainNetworkCompletableFake

  1. 按 alt-enter 将挂起修饰符添加到层次结构中的所有函数
  2. 用这个函数替换 fetchNextTitle
override suspend fun fetchNextTitle() = completable.await()

TitleRepository.kt

  • 删除 refreshTitleWithCallbacks 函数,因为它不再使用。

运行应用程序

再次运行应用程序,一旦编译完成,您将看到它使用协程从 ViewModelRoomRetrofit 一直在加载数据!

10. 直接测试协程

在本练习中,您将编写一个直接调用 suspend 函数的测试。

由于 refreshTitle 作为公共 API 公开,因此将直接进行测试,展示如何从测试中调用协程函数。

这是您在上一个练习中实现的 refreshTitle 函数:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

编写一个调用挂起函数的测试

在有两个 TODOS 的 test 文件夹中打开 TitleRepositoryTest.kt

尝试从第一次测试 whenRefreshTitleSuccess_insertsRows 调用 refreshTitle

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   subject.refreshTitle()
}

由于 refreshTitle 是一个挂起函数,Kotlin 除了从协程或另一个挂起函数外不知道如何调用它,你会得到一个编译器错误,比如“挂起函数 refreshTitle 应该只从一个协程或另一个挂起函数调用”。

测试运行器对协程一无所知,因此我们无法将此测试设为挂起函数。 我们可以像在 ViewModel 中一样使用 CoroutineScope 启动协程,但是测试需要在协程返回之前运行协程完成。一旦测试函数返回,测试就结束了。 以 launch 开始的协程是异步代码,可能会在未来某个时候完成。 因此,要测试该异步代码,您需要某种方式来告诉测试等待协程完成。由于 launch 是一个非阻塞调用,这意味着它会立即返回并且可以在函数返回后继续运行协程——它不能用于测试。例如:

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   // launch starts a coroutine then immediately returns
   GlobalScope.launch {
       // since this is asynchronous code, this may be called *after* the test completes
       subject.refreshTitle()
   }
   // test function returns immediately, and
   // doesn't see the results of refreshTitle
}

此测试有时会失败。 对 launch 的调用将立即返回并与测试用例的其余部分同时执行。 该测试无法知道 refreshTitle 是否已运行 - 并且任何诸如检查数据库是否已更新之类的断言都是不稳定的。并且,如果 refreshTitle 抛出异常,则不会在测试调用堆栈中抛出。 相反,它将被扔到 GlobalScope 的未捕获异常处理程序中。

kotlinx-coroutines-test 具有 runBlockingTest 函数,该函数在调用挂起函数时会阻塞。 当 runBlockingTest 调用挂起函数或启动新的协程时,它默认立即执行。您可以将其视为将挂起函数和协程转换为正常函数调用的一种方式。

此外,runBlockingTest 将为您重新抛出未捕获的异常。 这使得在协程抛出异常时更容易测试。

重要提示:函数 runBlockingTest 将始终阻止调用者,就像常规函数调用一样。 协程将在同一个线程上同步运行。 您应该避免在应用程序代码中使用 runBlocking 和 runBlockingTest,而更喜欢立即返回的 launch

runBlockingTest 只能在测试中使用,因为它以测试控制的方式执行协程,而 runBlocking 可用于为协程提供阻塞接口

使用一个协程实现测试

使用 runBlockingTest 包装对 refreshTitle 的调用,并从 subject.refreshTitle() 中移除 GlobalScope.launch 包装器。

TitleRepositoryTest.kt

@Test
fun whenRefreshTitleSuccess_insertsRows() = runBlockingTest {
   val titleDao = TitleDaoFake("title")
   val subject = TitleRepository(
           MainNetworkFake("OK"),
           titleDao
   )

   subject.refreshTitle()
   Truth.assertThat(titleDao.nextInsertedOrNull()).isEqualTo("OK")
}

此测试使用提供的 fakes 来检查 refreshTitle 是否将“OK”插入到数据库中。

当测试调用 runBlockingTest 时,它会阻塞直到 runBlockingTest 启动的协程完成。 然后在内部,当我们调用 refreshTitle 时,它使用常规的挂起和恢复机制来等待数据库行添加到我们的 fake 中。

测试协程完成后,runBlockingTest 返回。

写一个超时测试

我们想为网络请求添加一个短暂的超时。 让我们先编写测试然后实现超时。 创建一个新的测试:

TitleRepositoryTest.kt

@Test(expected = TitleRefreshError::class)
fun whenRefreshTitleTimeout_throws() = runBlockingTest {
   val network = MainNetworkCompletableFake()
   val subject = TitleRepository(
           network,
           TitleDaoFake("title")
   )

   launch {
       subject.refreshTitle()
   }

   advanceTimeBy(5_000)
}

此测试使用提供的 fake MainNetworkCompletableFake,这是一种网络 fake ,旨在暂停调用者,直到测试继续他们。 当 refreshTitle 尝试发出网络请求时,它将永远挂起,因为我们要测试超时。

然后,它启动一个单独的协程来调用 refreshTitle。 这是测试超时的关键部分,超时应该发生在与 runBlockingTest 创建的协程不同的协程中。通过这样做,我们可以调用下一行,advancedTimeBy(5_000),这将使时间提前 5 秒并导致另一个协程超时。

这是一个完整的超时测试,一旦我们实现超时就会通过。

现在运行它,看看会发生什么:

Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]

runBlockingTest 的特点之一是它不会让你在测试完成后泄漏协程。 如果有任何未完成的协程,比如我们的启动协程,在测试结束时,它将导致测试失败。

添加超时

打开 TitleRepository 并为网络获取添加 5 秒超时。 您可以使用 withTimeout 函数来做到这一点:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = withTimeout(5_000) {
           network.fetchNextTitle()
       }
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

运行测试。 当您运行测试时,您应该看到所有测试都通过了!

image.png

在下一个练习中,您将学习如何使用协程编写高阶函数。

runBlockingTest 依赖于 TestCoroutineDispatcher 来控制协程。

因此,在使用 runBlockingTest 时注入 TestCoroutineDispatcherTestCoroutineScope 是个好主意。 这具有使协程成为单线程的效果,并提供在测试中显式控制所有协程的能力。

如果您不想更改协程的行为(例如在集成测试中),您可以将 runBlocking 与所有调度程序的默认实现一起使用。

runBlockingTest 是实验性的,目前有一个错误,如果协程切换到在另一个线程上执行协程的调度程序,它会导致测试失败。 预计最终稳定版不会有此错误。

11. 在高阶函数中使用协程

在本练习中,您将重构 MainViewModel 中的 refreshTitle 以使用通用数据加载功能。 这将教您如何构建使用协程的高阶函数。

refreshTitle 的当前实现有效,但我们可以创建一个始终显示 spinner 的通用数据加载协程。 这在响应多个事件加载数据并希望确保加载spinner 始终显示的代码库中可能会有所帮助。

查看当前实现,除了 repository.refreshTitle() 之外的每一行都是样板,用于显示 spinner 和显示错误。

// MainViewModel.kt

fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           // this is the only part that changes between sources
           repository.refreshTitle() 
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

重要提示:尽管我们在此 Codelab 中仅使用 viewModelScope,但通常可以在任何有意义的地方添加范围。 如果不再需要,请不要忘记取消它。

例如,您可以在 RecyclerView Adapter 中声明一个来执行 DiffUtil 操作。

在高阶函数中使用协程

将此代码添加到 MainViewModel.kt

private fun launchDataLoad(block: suspend () -> Unit): Job {
   return viewModelScope.launch {
       try {
           _spinner.value = true
           block()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

现在重构 refreshTitle() 以使用这个高阶函数。

MainViewModel.kt

fun refreshTitle() {
   launchDataLoad {
       repository.refreshTitle()
   }
}

通过抽象显示加载 spinner 和显示错误的逻辑,我们简化了加载数据所需的实际代码。 显示 spinner 或显示错误很容易推广到任何数据加载,而每次都需要指定实际的数据源和目标。

为了构建这个抽象,launchDataLoad 需要一个参数块,它是一个挂起 lambda。 挂起 lambda 允许您调用挂起函数。 这就是 Kotlin 如何实现我们在此 Codelab 中一直使用的协程构建器 launchrunBlocking

// suspend lambda

block: suspend () -> Unit

要制作挂起 lambda,请从 suspend 关键字开始。 函数箭头和返回类型 Unit 完成了声明。

您通常不必声明自己的挂起 lambda,但它们有助于创建这样的抽象,以封装重复的逻辑!

12. 在 WorkManager 中使用协程

在本练习中,您将学习如何使用 WorkManager 中基于协程的代码。

什么是 WorkManager

Android 上有许多选项可用于延迟后台工作。 本练习向您展示如何将 WorkManager 与协程集成。 WorkManager 是一个兼容、灵活且简单的库,用于可延迟的后台工作。 WorkManager 是 Android 上这些用例的推荐解决方案。

WorkManagerAndroid Jetpack 的一部分,是一个用于后台工作的架构组件,需要结合机会主义和保证执行。 机会执行意味着 WorkManager 会尽快完成您的后台工作。保证执行意味着 WorkManager 将处理在各种情况下开始工作的逻辑,即使您离开应用程序也是如此。

因此,对于必须最终完成的任务,WorkManager 是一个不错的选择。

一些可以很好地使用 WorkManager 的任务示例:

  • 上传日志
  • 对图像应用滤镜并保存图像
  • 定期将本地数据与网络同步

要了解有关 WorkManager 的更多信息,请查看文档

WorkManager 中使用协程

WorkManager 为不同的用例提供其基本 ListenableWorker 类的不同实现。

最简单的 Worker 类允许我们通过 WorkManager 执行一些同步操作。然而,到目前为止,我们已经将我们的代码库转换为使用协程和挂起函数,使用 WorkManager 的最佳方法是通过 CoroutineWorker 类,该类允许将我们的 doWork() 函数定义为挂起函数。

首先,打开 RefreshMainDataWork。 它已经扩展了 CoroutineWorker,你需要实现 doWork

suspend doWork 函数中,从存储库中调用 refreshTitle() 并返回适当的结果!

完成 TODO 后,代码将如下所示:

override suspend fun doWork(): Result {
   val database = getDatabase(applicationContext)
   val repository = TitleRepository(network, database.titleDao)

   return try {
       repository.refreshTitle()
       Result.success()
   } catch (error: TitleRefreshError) {
       Result.failure()
   }
}

注意 CoroutineWorker.doWork() 是一个挂起函数。 与更简单的 Worker 类不同,此代码不会在 WorkManager 配置中指定的 Executor 上运行,而是使用 coroutineContext 成员中的调度程序(默认为 Dispatchers.Default)。

测试我们的 CoroutineWorker

没有测试,任何代码库都不应该是完整的。

WorkManager 提供了几种不同的方法来测试您的 Worker 类,要了解有关原始测试基础架构的更多信息,您可以阅读文档

WorkManager v2.1 引入了一组新的 API,以支持一种更简单的方法来测试 ListenableWorker 类,从而支持 CoroutineWorker。 在我们的代码中,我们将使用这些新 API 之一:TestListenableWorkerBuilder

要添加我们的新测试,请更新 androidTest 文件夹下的 RefreshMainDataWorkTest 文件。

该文件的内容是:

package com.example.android.kotlincoroutines.main

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.example.android.kotlincoroutines.fakes.MainNetworkFake
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4


@RunWith(JUnit4::class)
class RefreshMainDataWorkTest {

@Test
fun testRefreshMainDataWork() {
   val fakeNetwork = MainNetworkFake("OK")

   val context = ApplicationProvider.getApplicationContext<Context>()
   val worker = TestListenableWorkerBuilder<RefreshMainDataWork>(context)
           .setWorkerFactory(RefreshMainDataWork.Factory(fakeNetwork))
           .build()

   // Start the work synchronously
   val result = worker.startWork().get()

   assertThat(result).isEqualTo(Result.success())
}

}

在我们开始测试之前,我们告诉 WorkManager 有关工厂的信息,以便我们可以注入 fake 网络。

测试本身使用 TestListenableWorkerBuilder 创建我们的工作程序,然后我们可以调用 startWork() 方法运行它。

WorkManager 只是如何使用协程来简化 API 设计的一个例子。

13. 恭喜!

在此 Codelab 中,我们介绍了在应用程序中开始使用协程所需的基础知识!

我们涵盖了:

  1. 如何从 UI 和 WorkManager 作业将协程集成到 Android 应用程序以简化异步编程,
  2. 如何在 ViewModel 中使用协程从网络获取数据并将其保存到数据库而不阻塞主线程。
  3. 以及如何在 ViewModel 完成后取消所有协程。

为了测试基于协程的代码,我们涵盖了测试行为以及从测试中直接调用挂起函数。

学到更多

查看“学习采用 Kotlin Flow 和 LiveData 的高级协程”Codelab,了解更多 Android 上的高级协程用法。

要了解有关协程中取消和异常的更多信息,请查看此系列文章: Part 1: Coroutines, Part 2: Cancellation in coroutines, 和 Part 3: Exceptions in coroutines

Kotlin 协程具有许多本 Codelab 未涵盖的功能。 如果您有兴趣了解有关 Kotlin 协程的更多信息,请阅读 JetBrains 发布的协程指南。 另请查看“使用 Kotlin 协程提高应用程序性能”,了解更多 Android 协程的使用模式。