(已弃用) 在 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 上使用线程,包括主线程、后台线程和回调。

您将学习的内容

  • 调用使用协程编写的代码并获取结果。
  • 使用 suspend 函数使异步代码顺序执行。
  • 使用 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.pngRun 按钮,然后选择模拟器或连接您的 Android 设备,该设备必须能够运行 Android Lollipop(支持的最低 SDK 为 21)。此时应显示 Kotlin Coroutines 屏幕

1d65055551232a9.png

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

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

cbc7d16909facb7c.png

  1. MainActivity 显示 UI,注册点击监听器,并可以显示 Snackbar。它将事件传递给 MainViewModel,并根据 MainViewModel 中的 LiveData 更新屏幕。
  2. MainViewModelonMainViewClicked 中处理事件,并将使用 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 Coroutines 发布页面 找到 Coroutines 库的最新版本号,以替换“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 中运行。作用域通过其 Job 控制协程的生命周期。取消作用域的 Job 时,会取消在该作用域中启动的所有协程。在 Android 上,当用户离开 ActivityFragment 时,可以使用作用域取消所有正在运行的协程。作用域还允许您指定默认的调度器。调度器控制协程在哪一个线程上运行。

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

使用 viewModelScope

AndroidX lifecycle-viewmodel-ktx 库为 ViewModel 添加了一个 CoroutineScope,该 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 的 Job 被取消时,此 Job/Scope 中的所有协程都将被取消。如果用户在 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 配置为使用 kotlinx-coroutines-test 中的 TestCoroutineDispatcher。这允许测试推进用于测试的虚拟时钟,并允许代码在单元测试中使用 Dispatchers.Main

setup 方法中,使用测试替身(starter 代码中提供的网络和数据库的假实现)创建了 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 taps"

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

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

运行现有测试

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

您应该会看到测试通过!并且运行时间应该远少于一秒钟。

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

7. 从回调迁移到协程

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

在将架构的各个部分切换为使用协程之前,最好先了解它们各自负责的功能。

  1. MainDatabase 使用 Room 实现了一个数据库,用于保存和加载 Title
  2. MainNetwork 实现了一个网络 API,用于获取新标题。它使用 Retrofit 来获取标题。 Retrofit 配置为随机返回错误或模拟数据,但在其他方面 behaves 就像在进行真实的网络请求。
  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 函数。定义一个使用 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
}

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

而且,如果您从协程中抛出异常,该协程默认会取消其父级。这意味着可以轻松地同时取消多个相关任务。

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

再次运行应用,选择 start 配置并按 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 ExecutorService 中的线程上调用回调。
  2. 调用者不必向此函数传递回调。他们可以依靠 suspend 和 resume 来获取结果或错误。

再次运行应用

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

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

9. Room 和 Retrofit 中的协程

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

Room 中的协程

首先打开 MainDatabase.kt 并将 insertTitle 标记为 suspend 函数

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 更改为 suspend 函数。同时将返回类型从 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 一起使用 suspend 函数,您需要做两件事

  1. 向函数添加 suspend 修饰符
  2. 从返回类型中移除 Call 包装器。此处我们返回的是 String,但您也可以返回复杂的 json 支持类型。如果您仍然想提供 Retrofit 的完整 Result 访问,您可以从 suspend 函数返回 Result<String> 而不是 String

Retrofit 将自动使 suspend 函数实现主线程安全,以便您可以直接从 Dispatchers.Main 调用它们。

使用 Room 和 Retrofit

现在 Room 和 Retrofit 支持 suspend 函数了,我们可以在我们的仓库中使用它们。打开 TitleRepository.kt,看看使用 suspend 函数如何大大简化逻辑,即使与阻塞版本相比也是如此

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

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

更好的是,我们摆脱了 withContext。由于 Room 和 Retrofit 都提供主线程安全的 suspend 函数,因此从 Dispatchers.Main 协调这项异步工作是安全的。

修复编译器错误

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

浏览项目并修复生成的编译器错误,将函数更改为 suspend。以下是每个错误的快速解决方案

TestingFakes.kt

更新测试替身以支持新的 suspend 修饰符。

TitleDaoFake

  1. 按下 alt-enter (Mac 上是 option-enter),向层次结构中的所有函数添加 suspend 修饰符

MainNetworkFake

  1. 按下 alt-enter,向层次结构中的所有函数添加 suspend 修饰符
  2. fetchNextTitle 替换为此函数
override suspend fun fetchNextTitle() = result

MainNetworkCompletableFake

  1. 按下 alt-enter,向层次结构中的所有函数添加 suspend 修饰符
  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)
   }
}

编写一个调用 suspend 函数的测试

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

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

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

   subject.refreshTitle()
}

由于 refreshTitle 是一个 suspend 函数,Kotlin 无法从除协程或另一个 suspend 函数之外的地方调用它,您将收到类似于 "Suspend function refreshTitle should be called only from a coroutine or another suspend function." (Suspend 函数 refreshTitle 只能从协程或另一个 suspend 函数中调用。)的编译器错误。

测试运行器对协程一无所知,所以我们不能把这个测试变成一个 suspend 函数。我们可以像在 ViewModel 中那样使用 CoroutineScopelaunch 一个协程,但是测试需要在它们返回之前运行协程直到完成。一旦测试函数返回,测试就结束了。用 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 函数,它在调用 suspend 函数时会阻塞。当 runBlockingTest 调用 suspend 函数或 launches 新协程时,它默认会立即执行。您可以将其视为将 suspend 函数和协程转换为正常函数调用的方法。

此外,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 时,它使用常规的 suspend 和 resume 机制等待数据库行添加到我们的替身中。

测试协程完成后,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,它是一个 suspend lambda。suspend lambda 允许您调用 suspend 函数。Kotlin 就是这样实现我们在本 codelab 中一直在使用的协程构建器 launchrunBlocking 的。

// suspend lambda

block: suspend () -> Unit

要创建 suspend lambda,首先使用 suspend 关键字。函数箭头和返回类型 Unit 完成声明。

您通常无需声明自己的 suspend lambda,但它们有助于创建像这样封装重复逻辑的抽象!

12. 将协程与 WorkManager 一起使用

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

什么是 WorkManager

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

WorkManager 是 Android Jetpack 的一部分,也是一个 架构组件,用于需要机会性执行和保证执行结合的后台工作。机会性执行意味着 WorkManager 会尽快执行您的后台工作。保证执行意味着 WorkManager 会负责在各种情况下启动您工作的逻辑,即使您离开了您的应用。

因此,WorkManager 是那些最终必须完成的任务的不错选择。

适合使用 WorkManager 的任务示例

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

将协程与 WorkManager 一起使用

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

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

要开始,打开 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() 是一个 suspend 函数。与更简单的 Worker 类不同,此代码不在 WorkManager 配置中指定的 Executor 上运行,而是使用 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 创建我们的 worker,然后我们可以通过调用 startWork() 方法运行它。

WorkManager 只是一个例子,说明了协程如何用于简化 API 设计。

13. 恭喜!

在本 Codelab 中,我们涵盖了您开始在应用中使用协程所需的基础知识!

我们涵盖了

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

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

了解更多

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

要了解有关协程中取消和异常的更多信息,请查阅此系列文章:第一部分:协程第二部分:协程中的取消第三部分:协程中的异常

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