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

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 可能与其他版本一起使用,但某些内容可能丢失或外观不同)。

如果您在完成本 Codelab 时遇到任何问题(代码错误、语法错误、措辞不清等),请通过 Codelab 左下角的“报告错误”链接报告问题。

2. 设置

下载代码

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

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

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

常见问题解答

3. 运行起始示例应用程序

首先,让我们看看起始示例应用程序是什么样的。 请按照以下说明在 Android Studio 中打开示例应用程序。

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

1d65055551232a9.png

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

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

cbc7d16909facb7c.png

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

将协程添加到项目

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

Android 上的协程可作为核心库和 Android 特定扩展提供

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

入门应用程序已经在 build.gradle 中包含了依赖项。 创建新的应用程序项目时,您需要打开 build.gradle (Module: app) 并将协程依赖项添加到项目中。

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

您可以在 Kotlin 协程发行页面 上找到要替换为“x.x.x”的协程库的最新版本号。

4. Kotlin 中的协程

在 Android 上,避免阻塞主线程至关重要。 主线程是一个单线程,用于处理 UI 的所有更新。 它也是调用所有点击处理程序和其他 UI 回调的线程。 因此,它必须平稳运行才能保证良好的用户体验。

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

观看下面的视频,了解协程如何通过引入主线程安全性为我们在 Android 上解决此问题。

回调模式

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

让我们来看一个回调模式的示例。

// 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 的函数时,它不会像普通函数调用那样阻塞直到该函数返回,而是会 **挂起** 执行,直到结果准备就绪,然后使用结果从 **挂起** 的地方 **恢复** 执行。在挂起等待结果期间,**它会解除正在运行的线程的阻塞**,以便其他函数或协程可以运行。

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

// 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 关键字发挥作用的地方。

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

// 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 { ... }

你将在下一节中将协程引入示例应用程序。

5. 使用协程控制 UI

在本练习中,你将编写一个协程来在延迟后显示一条消息。要开始,请确保你在 Android Studio 中打开了模块 start

理解 CoroutineScope

在 Kotlin 中,所有协程都在 CoroutineScope 中运行。作用域通过其作业控制协程的生命周期。当你取消作用域的作业时,它会取消在该作用域中启动的所有协程。在 Android 上,你可以使用作用域来取消所有正在运行的协程,例如,当用户从 ActivityFragment 导航离开时。作用域还允许你指定默认调度器。调度器控制协程运行的线程。

对于由 UI 启动的协程,通常在 Dispatchers.Main 上启动它们是正确的,Dispatchers.Main 是 Android 上的主线程。在 Dispatchers.Main 上启动的协程在挂起时不会阻塞主线程。由于 ViewModel 协程几乎总是更新主线程上的 UI,因此在主线程上启动协程可以节省额外的线程切换。在主线程上启动的协程可以在启动后随时切换调度器。例如,它可以使用另一个调度器来解析主线程之外的大型 JSON 结果。

使用 viewModelScope

AndroidX lifecycle-viewmodel-ktx 库向 ViewModel 添加了一个 CoroutineScope,该作用域配置为启动与 UI 相关的协程。要使用此库,你必须将其包含在项目的 build.gradle (Module: 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 并再次运行它来看到这种情况发生。加载动画不会显示,所有内容都会在一秒钟后“跳转”到最终状态。

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 中启动一个协程。这意味着当我们传递给 viewModelScope 的作业被取消时,此作业/作用域中的所有协程都将被取消。如果用户在 delay 返回之前离开了 Activity,则当在销毁 ViewModel 时调用 onCleared 时,此协程将自动被取消。
  2. 由于 viewModelScope 的默认调度器是 Dispatchers.Main,因此此协程将在主线程中启动。稍后我们将看到如何使用不同的线程。
  3. 函数 delay 是一个 suspend 函数。这在 Android Studio 中用左侧边栏中的 716807c07961aacd.png 图标显示。即使此协程在主线程中运行,delay 也不会阻塞线程一秒钟。相反,调度器将安排协程在一秒钟后的下一条语句处恢复。

继续运行它。当你单击主视图时,你应该在一秒钟后看到一个 Snackbar。

在下一节中,我们将考虑如何测试此函数。

6. 通过行为测试协程

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

回顾现有代码

test 文件夹中打开 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 以使用 TestCoroutineDispatcher(来自 kotlinx-coroutines-test)。这允许测试为测试推进虚拟时钟,并允许代码在单元测试中使用 Dispatchers.Main

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

对于此测试,模拟只需要满足 MainViewModel 的依赖项。在本 codelab 的后面,你将更新模拟以支持协程。

编写一个控制协程的测试

添加一个新的测试,以确保在单击主视图后一秒钟更新点击次数

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 taps”,然后在 1 秒后更新为“1 taps”。

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

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

运行现有的测试

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

您应该看到测试通过!并且它应该在不到一秒钟的时间内运行。

在下一个练习中,您将学习如何将现有的回调 API 转换为使用协程。

7. 从回调迁移到协程

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

在将它们切换为使用协程之前,了解架构的每个部分所负责的内容是一个好主意。

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

由于网络请求由 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.value = false 清除加载微调器
  • 如果它遇到错误,它会告诉一个 SnackBar 显示并清除微调器

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

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

协程版本

让我们用协程重写 refreshTitle

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

TitleRepository.kt

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

完成此 Codelab 后,您将更新此代码以使用 Retrofit 和 Room 来获取新标题并使用协程将其写入数据库。现在,它只会花费 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 将进行网络请求和数据库查询,它也可以使用协程来公开一个主线程安全的接口。这意味着从主线程调用它是安全的。

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

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

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

在此协程执行任何操作之前,它会启动加载微调器 - 然后它会像普通函数一样调用 refreshTitle。但是,由于 refreshTitle 是一个挂起函数,因此它的执行方式不同于普通函数。

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

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

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

而且,如果您从协程中抛出一个异常 - 该协程默认情况下会取消其父协程。这意味着很容易一起取消几个相关的任务。

然后,在一个 finally 块中,我们可以确保在查询运行后微调器始终被关闭。

通过选择 启动 配置,然后按 execute.png 再次运行应用程序,您应该在点击任何地方时看到加载微调器。标题将保持不变,因为我们还没有连接我们的网络或数据库。

在下一个练习中,您将更新存储库以实际执行工作。

8. 从阻塞代码创建主线程安全的函数

在本练习中,您将学习如何切换协程运行的线程,以便实现 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 方法告诉调用者刷新失败。

这种基于回调的实现是 **主线程安全的**,因为它不会阻塞主线程。但是,它必须使用回调来通知调用者工作何时完成。它还会在它切换到的 BACKGROUND 线程上调用回调。

从协程中调用阻塞调用

在没有向网络或数据库引入协程的情况下,我们可以使用协程使这段代码 **主线程安全**。这将让我们摆脱回调,并允许我们将结果传递回最初调用它的线程。

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

为了在任何调度器之间切换,协程使用 withContext。调用 withContext 会切换到另一个调度器,**仅针对 lambda**,然后带着 lambda 的结果返回到调用它的调度器。

默认情况下,Kotlin 协程提供了三个调度器:MainIODefault。IO 调度器针对 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 将其结果返回给调用它的调度器,在本例中为 Dispatchers.Main。回调版本在 BACKGROUND 执行器服务中的一个线程上调用回调。
  2. 调用者不必为此函数传递回调。他们可以依赖于挂起和恢复来获取结果或错误。

再次运行应用程序

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

在下一步中,您将把协程集成到 Room 和 Retrofit 中。

9. Room & Retrofit 中的协程

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

Room 中的协程

首先打开 MainDatabase.kt 并将 insertTitle 设为挂起函数

MainDatabase.kt

// add the suspend modifier to the existing insertTitle

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

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

并且,这就是在 Room 中使用协程所需要做的全部工作。非常不错。

Retrofit 中的协程

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

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. 在函数中添加一个挂起修饰符
  2. 从返回类型中删除 Call 包装器。这里我们返回的是 String,但您也可以返回复杂的 json 支持类型。如果您仍然想提供对 Retrofit 的完整 Result 的访问权限,您可以从挂起函数中返回 Result<String> 而不是 String

Retrofit 会自动使挂起函数 **主线程安全**,因此您可以直接从 Dispatchers.Main 调用它们。

使用 Room 和 Retrofit

现在 Room 和 Retrofit 支持挂起函数,我们可以从我们的存储库中使用它们。打开 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。由于 Room 和 Retrofit 都提供 **主线程安全** 的挂起函数,因此可以安全地在 Dispatchers.Main 中协调此异步工作。

修复编译器错误

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

浏览项目并通过将函数更改为挂起的 created 来修复编译器错误。以下是每个函数的快速解决方案

TestingFakes.kt

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

TitleDaoFake

  1. 按 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 函数,因为它不再使用。

运行应用程序

再次运行应用程序,编译完成后,您将看到它正在使用协程从 ViewModel 到 Room 和 Retrofit 加载数据!

恭喜,您已将此应用程序完全切换为使用协程!最后,我们将讨论一下如何测试我们刚刚做的事情。

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)
   }
}

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

test 文件夹中打开 TitleRepositoryTest.kt,其中有两个 TODO。

尝试从第一个测试 whenRefreshTitleSuccess_insertsRows 中调用 refreshTitle

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

   subject.refreshTitle()
}

由于 refreshTitle 是一个 suspend 函数,因此 Kotlin 除非从协程或其他挂起函数中调用它,否则不知道如何调用它,您将收到一个类似“挂起函数 refreshTitle 只能从协程或其他挂起函数中调用。”的编译器错误。

测试运行程序对协程一无所知,因此我们无法将此测试设为挂起函数。我们可以使用 CoroutineScopeViewModellaunch 一个协程,但是测试需要在返回之前完成运行协程。一旦测试函数返回,测试就结束了。使用 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会为您重新抛出未捕获的异常。这使得测试协程何时抛出异常变得更加容易。

使用一个协程实现测试

将对refreshTitle的调用用runBlockingTest包装起来,并从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")
}

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

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

在测试协程完成后,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)
}

此测试使用提供的模拟MainNetworkCompletableFake,它是一个网络模拟,旨在挂起调用方,直到测试继续它们。当refreshTitle尝试进行网络请求时,它会永远挂起,因为我们要测试超时。

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

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

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

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

runBlockingTest的功能之一是,它不会允许您在测试完成后泄漏协程。如果在测试结束时有任何未完成的协程,比如我们的 launch 协程,它将使测试失败。

添加超时

打开TitleRepository,在网络获取中添加五秒的超时。您可以使用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)
   }
}

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

17c2c9cab594f2f5.png

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

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

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

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

回顾当前的实现,除了repository.refreshTitle()之外,每一行都是显示加载指示器和显示错误的样板。

// 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
       }
   }
}

在高阶函数中使用协程

将此代码添加到 MainViewModel.kt 中

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()
   }
}

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

为了构建此抽象,launchDataLoad接受一个参数block,它是一个挂起 lambda 表达式。挂起 lambda 表达式允许您调用挂起函数。这就是 Kotlin 实现我们在此代码实验室中一直在使用的协程构建器launchrunBlocking的方式。

// suspend lambda

block: suspend () -> Unit

要创建一个挂起 lambda 表达式,从suspend关键字开始。函数箭头和返回类型Unit完成了声明。

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

12. 将协程与 WorkManager 一起使用

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

什么是 WorkManager

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

WorkManager 是Android Jetpack的一部分,并且是架构组件,用于需要结合机会执行和保证执行的后台工作。机会执行意味着 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 配置中指定的执行器上运行,而是使用Dispatchers.Default。您可以使用withContext()切换到其他调度程序。

测试我们的 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 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有关工厂的信息,以便我们可以注入模拟网络。

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

WorkManager 只是协程如何简化 API 设计的一个示例。

13. 恭喜!

在本代码实验室中,我们已经涵盖了开始在您的应用程序中使用协程所需的基础知识!

我们涵盖了

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

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

了解更多

查看“ Kotlin Flow 和 LiveData 的高级协程” 代码实验室,了解更多关于在 Android 上使用高级协程的知识。

要了解更多关于协程中的取消和异常,请查看以下文章系列:第一部分:协程第二部分:协程中的取消第三部分:协程中的异常

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