在 Android 应用中使用 Kotlin 协程

在此 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 左下角的报告错误链接报告相应问题。

下载代码

点击下面的链接可下载此 Codelab 的所有代码:

下载 Zip 文件

…或从命令行使用下列命令克隆 GitHub 代码库:

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

常见问题解答

首先,我们来看看起始示例应用的外观。按照下列说明在 Android Studio 中打开示例应用。

  1. 如果已下载 kotlin-coroutines zip 文件,请将其解压缩。
  2. 在 Android Studio 中打开 coroutines-codelab 项目
  3. 选择 start 应用模块。
  4. 点击 execute.pngRun 按钮,然后选择模拟器或者连接必须能够运行 Android Lollipop(支持的最小 SDK 为 21)的 Android 设备。此时应显示 Kotlin 协程屏幕:

a7d14a8aa9b85a6b.png

在您点按屏幕后,此初始应用会使用线程在经过短暂延迟后增加计数。它还会从网络中提取新标题并将其显示在屏幕上。现在就试试看吧,您应该会看到计数和消息在短暂延迟后出现变化。在此 Codelab 中,您会将此应用转换为使用协程。

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

cbc7d16909facb7c.png

  1. MainActivity 显示界面、注册点击监听器,并且可以显示 Snackbar。它将事件传递给 MainViewModel,并根据 MainViewModel 中的 LiveData 更新屏幕。
  2. MainViewModel 处理 onMainViewClicked 中的事件,并将使用 LiveData.MainActivity 通信
  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 协程版本页面上找到协程库的最新版版本号,以替代“xxx”。

在 Android 上,避免阻塞主线程是非常必要的。主线程是一个处理所有界面更新的线程,也是调用所有点击处理程序和其他界面回调的线程。因此,主线程必须顺畅运行才能确保出色的用户体验。

为了避免用户在使用您的应用时感觉到任何卡顿,主线程必须每隔 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 { ... }

在下一部分中,您将向示例应用引入协程。

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

了解 CoroutineScope

在 Kotlin 中,所有协程都在 CoroutineScope 中运行。作用域在其整个作业期间会控制协程的生命周期。如果取消某个作用域的作业,则该作用域内启动的所有协程也将取消。在 Android 上,在一些情况下,例如当用户离开 ActivityFragment 时,您可以使用作用域取消所有正在运行的协程。作用域还允许您指定默认调度程序。调度程序可以控制哪个线程运行协程。

对于界面启动的协程,通常在 Dispatchers.Main(Android 上的主线程)上启动这类协程是正确的。在 Dispatchers.Main 上启动的协程在挂起期间不会阻塞主线程。由于 ViewModel 协程几乎总是在主线程上更新界面,因此在主线程上启动协程可避免额外的线程切换。在主线程上启动的协程可在启动后随时切换调度程序。例如,它可以使用另一个调度程序从主线程外解析大型 JSON 结果。

使用 viewModelScope

AndroidX lifecycle-viewmodel-ktx 库将 CoroutineScope 添加到已配置为启动界面相关协程的 ViewModel 中。要使用此库,您必须将其添加到项目的 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 会阻塞当前线程,因此,如果在主线程上调用它,它会导致界面冻结。在用户点击主视图的一秒钟后,它会请求信息提示控件。

从代码中移除 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")
   }
}

此代码执行的操作相同,即等待 1 秒钟后显示信息提示控件。不过,它们存在一些重要区别:

  1. viewModelScope. launch 将在 viewModelScope 中启动协程。这意味着,当我们传递给 viewModelScope 的作业取消时,此作业/作用域内的所有协程都将取消。如果用户在 delay 返回之前离开了 Activity,那么在 ViewModel 销毁后系统调用 onCleared 时,此协程将自动取消。
  2. 由于 viewModelScope 的默认调度程序为 Dispatchers.Main,因此此协程将在主线程中启动。稍后,我们将了解如何使用不同的线程。
  3. delay 函数属于 suspend 函数。在 Android Studio 的左侧边线中,此函数会以 716807c07961aacd.png 图标显示。虽然此协程在主线程上运行,但 delay 不会阻塞此线程 1 秒钟。相反,调度程序将安排协程在一秒钟内在下一个语句中恢复。

开始运行测试。点击主视图后,您应该会在一秒钟后看到信息提示控件。

在下一部分中,我们将探讨如何测试此函数。

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

查看现有代码

打开 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-test 中的 TestCoroutineDispatcher。这样一来,测试可以将用于测试的虚拟时钟拨快,并让代码可以使用单元测试中的 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.pngRun ‘MainViewModelTest'
  3. 今后运行测试时,您可以在工具栏中 execute.png 按钮旁边的配置中选择此测试配置。默认情况下,此配置将称为 MainViewModelTest

您应该会看到测试通过!实际运行时间应该比 1 秒钟短一些。

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

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

在将架构的各个部分转换为使用协程之前,最好先了解每个部分的作用。

  1. MainDatabase 使用 Room 实现一个数据库,以保存和加载 Title
  2. MainNetwork 实现一个网络 API,用于提取新标题。它使用 Retrofit 提取标题。Retrofit 配置为随机返回错误或模拟数据,但除此之外其行为就像是在发出实际网络请求一样。
  3. TitleRepository 实现了一个 API,用于通过结合来自网络和数据库的数据来提取或刷新标题。
  4. MainViewModel 表示屏幕的状态,并负责处理事件。它会指示代码库在用户点按屏幕时刷新标题。

由于网络请求由界面事件驱动,并且我们希望根据这些事件启动协程,那么自然而然应在 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 清除加载旋转图标
  • 如果出现错误,它会指示系统显示信息提示控件并清除旋转图标

请注意,系统不会向 onCompleted 回调函数传递 title。由于我们将所有标题写入 Room 数据库,因此界面通过观察由 Room 更新的 LiveData 来更新为最新标题。

在协程的更新中,我们将保持完全相同的行为。使用 Room 数据库等可观察的数据源自动让界面保持最新状态,不失为一种好模式。

协程版本

我们来使用协程重写 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,也就是 OK。尽管 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 块中,我们可以确保旋转图标始终在查询运行后关闭。

通过依次选择 start 配置并点按 execute.png 再次运行应用,您应该会在点按任意位置时看到加载旋转图标。由于我们尚未连接网络或数据库,标题不会发生变化。

在下一个练习中,您将更新代码库以实际执行操作。

在本练习中,您将学习如何切换运行协程的线程,以实现 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。

为了继续实现协程集成,我们将利用对稳定版 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 修饰符,系统会生成一些编译器错误,从中您会明白在实际项目中将函数更改为挂起函数时会发生的情况。

检查项目,并修正将函数更改为挂起函数时所产生的编译器错误。以下是快速解决各类问题的方法:

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!

恭喜,您已将此应用完全转换为使用协程!最后,我们简单介绍下如何测试我们所完成的工作。

在本练习中,您将编写一个直接调用 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()
}

由于 refreshTitlesuspend 函数,Kotlin 不知道如何调用此函数(除非从协程或另一个挂起函数调用),并且您会收到一个编译器错误,例如“Suspend function refreshTitle should be called only from a coroutine or another suspend function.”

测试运行程序完全不了解协程,因此无法将此测试设置为挂起函数。我们可以使用 CoroutineScope 对协程执行 launch 操作(例如在 ViewModel 中),不过,测试需要在协程返回之前运行协程至结束。测试函数返回后,测试即结束。通过 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 调用挂起函数或对新协程执行 launches 时,它会立即执行。您可以将它看作一种将挂起函数和协程转换为正常函数调用的方式。

此外,runBlockingTest 会为您重新抛出未捕获异常。这样,便可以在协程抛出异常时更轻松地进行测试。

使用一个协程实现测试

使用 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")
}

此测试使用提供的模拟对象来验证 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 的一项功能是它不允许您在测试完成后泄露协程。如果存在任何未完成的协程,例如我们的启动协程,在测试结束时都会导致测试失败。

添加超时

打开 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

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

在本练习中,您将重构 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 接受一个属于挂起 lambda 的参数 block。挂起 lambda 可让您调用挂起函数。Kotlin 就是通过这种方式实现我们在此 Codelab 中使用的 launchrunBlocking 协程构建器。

// suspend lambda

block: suspend () -> Unit

要创建挂起 lambda,应从 suspend 关键字着手。函数箭头和返回值类型 Unit 用于完成声明。

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

在本练习中,您将学习如何从 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 配置所指定的执行器上运行,而是使用 coroutineContext 成员的调度程序(默认为 Dispatchers.Default)。

测试我们的 CoroutineWorker

未经测试,任何代码库均不应完成。

WorkManager 提供了几种不同的 Worker 类测试方法。如需详细了解原始测试基础架构,请参阅相应文档

WorkManager v2.1 引入了一组新的 API 来支持更简单的 ListenableWorker 类测试方法,并最终推出了 CoroutineWorker。我们将在代码中使用其中一个名为 TestListenableWorkerBuilder 的新 API。

为了添加我们的新测试,更新 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 关于工厂的信息,以便我们注入虚构网络。

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

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

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

我们介绍了:

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

关于如何测试基于协程的代码,我们介绍了测试行为和直接从测试调用 suspend 函数两种方法。

了解详情

参阅“采用 Kotlin Flow 和 LiveData 的高级协程”Codelab,详细了解 Android 上的高级协程用法。

如需详细了解协程中的取消和异常,请参阅以下文章系列:第 1 部分:协程第 2 部分:协程中的取消第 3 部分:协程中的异常

Kotlin 协程的许多功能未在此 Codelab 中作介绍。如需详细了解 Kotlin 协程,请参阅 JetBrains 发布的协程指南。另请参阅“利用 Kotlin 协程提升应用性能”,了解 Android 上的更多协程使用模式。