在你的Android App中使用Kotlin协程

1.前言

通过本文,你将学习到如何在Android App中使用协程。这是一种管理后台线程的方法,可以使代码简洁,减少回调代码的使用。

协程是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

下面会通过一个简单的sample App来讲解,该App使用架构组件构建,使用的是回调的方式完成异步任务。

在本文结束,你将学习到如何在App使用协程从网络加载数据,你也将有能力将协程集成进你的App中。你将得到协程的最佳实践,以及如何使用协程编写测试代码。

准备

  • 熟悉架构组件,如ViewModel、LiveData、Repository以及Room
  • 熟练使用Kotlin语法,包括扩展函数及lambda语法
  • Android中线程的进本使用,包括主线程,后台线程以及回调。

如何做

  • 调用协程代码,并获取结果
  • 使用挂起函数使异步代码顺序调用
  • 使用launch和runBlocking来控制代码如何执行。
  • 学习使用suspendCoroutine转换APIs成协程
  • 将架构组件与协程配合使用
  • 学习协程测试的最佳实践

扩展阅读

Room使用介绍,请查看使用Room DAOs获取数据
架构组件使用介绍,请查看App架构指南
Kotlin语法介绍,请查看Kotlin语法基础
Android线程基础介绍,请查看后台处理指南

为了保证示例代码正常运行,请使用Android Studio3.6以上的版本。

2. 准备

下载示例代码

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

kotlin-coroutines仓库包含2 个工程。本文中使用的是coroutines-codelab工程,该工程包含2个module:

android_studio_folder.pngstart — 使用Android架构组件的构建的简单app,将在此app基础上添加协程使用
android_studio_folder.pngfinished_code — 该module已经使用了协程。

3.运行示例app

首先,我们将该示例app运行起来,看下它长什么样,将coroutines-codelab工程导入到AS中运行,运行效果如下:
在这里插入图片描述

当你单击屏幕时,这个启动的App使用线程来做延迟计数,它也将从网络获取新的标题,并显示在屏幕上。重新尝试下,在短暂的延迟之后,你将看到计数和消息更新。在该示例中,我们将使用协程来改造它。

该示例app使用了架构组件进行了UI和业务逻辑的分层,UI代码放在MainActivity中,业务逻辑放在MainViewModel中,花点时间熟悉下这个工程的结构。
在这里插入图片描述

  1. MainActivity显示UI,注册点击监听,显示Snackbar。传递事件到MainViewModel中,并依赖于MainViewModel中的LiveData数据更新屏幕。

  2. MainViewModelonMainViewClicked中处理事件,通过LiveDataMainActivity进行通信。

  3. Executors定义BACKGROUND,用于处理耗时的任务。

  4. TitleRepository从网络获取数据并保存到数据库中。

在你的App中引入协程

在你的app module中添加协程相关的依赖,代码如下:

dependencies {
  ...
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x" //kotlin协程的主要接口
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x" //Android主线程协程支持
}

协程与RxJava

如果你在你的工程中使用了RxJava,你可以通过使用 kotlin-coroutines-rx集成协程。

4. Kotlin中的协程

在Android中,避免主线程阻塞是基本的要求。主线程中只能用于处理UI更新。主线程也可以用于点击事件处理以及UI回调。只有这样,App才能运行流程,从而有良好的用户体验。

Android系统比较流畅的帧率是60fps, 换算成时间就是16ms,也就是UI渲染必须在该时间段内完成,否则就会出现卡帧的现象。

许多任务的花费的时间都会比这个时间长,例如解析大的JSON数据集,往数据库写入数据,或者从网络获取数据。因此,在主线程调用这些耗时的代码段会引起app的暂停,卡顿甚至冻结。如果你阻塞主线程太长时间,app可能会crash,系统会弹出应用无响应(ANR)的dialog。

回调方式
避免主线程阻塞的通常做法是使用异步回调。通过使用回调,你可以将耗时任务运行在后台线程,当任务完成时,通过回调向主线程通知结果。
看下面回调方式的代码示例

// 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将花费几秒甚至几分钟才能完成,主线程不可能等这么久。回调方式允许slowFetch调用运行在后台线程,并在任务完成后返回结果。

使用协程剔除callbacks

回调是一种处理异步调用的很好方式,然而它也有一些缺点。过多使用callbacks将导致代码很难阅读及理解。另外,回调在一些语法功能中是不允许的,例如异常处理。

Kotlin协程让你可以将基于回调的调用方式转成顺序调用。顺序调用的书写方式很容易阅读,这种方式也可以用在一些语法功能上,例如异常。

最后,它们将做相同的事情,等待结果返回,并继续执行,然而,代码形式上有很大的不同。

关键字suspend用于标识函数可以在协程中使用。当协程调用一个suspend标识的函数时,会像调用普通函数一样,它将挂起执行直到结果返回。当它挂起时,不会阻塞线程,从而其他函数和协程可以继续执行。

下面的示例代码将展示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关键字magic的地方。

同基于回调风格的代码比较,协程代码在不阻塞当前线程的前提下,可以达到同样的效果。协程可以将多个耗时任务串联起来,而不需要多个callback,例如下面的示例代码:

// 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中,当调用suspend函数时,await()是隐含的。

Kotlin中有一个方法Deferred.await(),用于等待async启动的协程返回的结果。

5. 使用协程控制UI

理解CoroutineScope

在Kotlin中,所有的协程都运行在CoroutineScope中。它通过job控制coroutines的生命周期。当你取消了该scope上的job, 该scope上所有启动的coroutine都会被取消。当ActivityFragment销毁时,你可以用一个scope取消所有运行的协程。Scopes也允许你运行在一个特定的dispatcher上。一个dispatcher控制着coroutine运行在哪个线程上。

对于UI启动的协程,会运行在Dispatchers.Main主线程上。运行在Dispatchers.Main线程上的协程挂起时,不会阻塞主线程。由于ViewModel协程几乎总是在UI主线程中更新,在主线程中启动协程会增加额外的线程切换。在主线程中启动的协程可以随时切换线程。例如,可以使用另外一个dispatcher解析大的JSON数据集。

Coroutines提供主线程安全
因为coroutines可以随时切换线程,并传递结果到原始线程中,所以在主线程中启动UI相关的coroutines是个不错的主意。

RoomRetrofit都提供了使用coroutines时的主线程安全特性,因此你在处理网络和数据库时不需要管理线程。这通常能使代码简洁。

然而,像列表排序或从文件读取等blocking code仍然需要明确声明main-safety,尽管使用了coroutines。如果你使用了网络请求或者数据操作的lib,并且没有支持coroutines,你仍然需要明确指定main-safety

使用viewModelScope

AndroidX 的lifecycle-viewmodel-ktx库提供了CoroutineScopeViewModels中,该ViewModels启动UI相关连的协程。为了使用该库,你必须将该lib引入到你app的build.gradle文件中

dependencies {
  ...
  implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x"
}

该lib添加了viewModelScope作为ViewModel类的扩展函数。该scope与Dispatchers.Main绑定,当viewModel被清理时,该scope会被自动取消。

从线程切换到协程

在处理耗时任务时,我们传统做法是使用后台线程进行处理。

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。

后面我们将移除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")
   }
}

这段代码,等待1s后会显示一个snackbar.然而,两者有很大的不同:

  1. viewModelScope.launch将在viewModelScope中启动一个协程,这意味一旦我们取消传递给viewModelScope的job,所有在该job/scope中的协程将被取消。如果用户在delay调用返回前销毁了Activity,协程将在ViewModel调用onCleared函数时自动取消。
  2. 由于viewModelScope有一个默认的dispatcher Dispatchers.Main,该协程将在主线程中启动。我们后续将看到如何在不同线程中使用协程。
    3.delay是一个suspend函数。在Android Studio中将展示一个 icon 在左侧空白。尽管该协程运行在主线程,delay不会阻塞该线程。相反,dispatcher将在1s后恢复协程。

6. 通过行为测试协程

协程测试通过kotlinx-coroutines-test Lib完成,该协程运行在Dispatchers.Main

下面是示例代码
打开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")
           ))
   }
}

1.InstantTaskExecutorRule是一个JUnit规则,用于配置LiveData同步的运行每一个任务

2.MainCoroutineScopeRule是一个自定义规则用于配置Dispatchers.Main来调用kotlinx-coroutines-test中的TestCoroutineDispatcher。这个允许单元测试运行在Dispatchers.Main

setup方法,Fake一个虚拟的MainViewModel实例,这个Fake ViewModel实现了网络和数据库数据,而不需要真实的网络或者数据库。

在该测试中,这些Fakes(假的、赝品)仅需要安全依赖MainViewModel.后续你将升级这些fakes以支持协程。

写一个控制协程的测试

添加一个测试确保当用户点击主view时,1s后taps可以被更新。
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被调用时,1s后,taps 上的文字将由“0 taps”更新为“1 taps”

该测试使用virtual-time用来控制由onMainViewClicked启动的协程。MainCoroutineScopeRule让你停止,恢复或者控制由Dispatchers.Main启动的协程。这里我们调用advanceTimeBy(1_000),从而让主dispatcher立即执行协程,并在1秒后恢复。

该测试是完全确定,它将总是按照相同的方式执行。并且,因为在Dispatchers.Main上启动的协程是完全可控的,不需要等待1s再设置值。

为了测试下效果,你可以在AS中运行该测试Case。

7. 从callbacks(回调)切换到coroutines(协程)

在该步骤中,你将开始在repository中使用协程。我们将添加协程到ViewModelRepositoryRoomRetrofit

在我们使用协程之前,我们需要先了解各架构组件的职责。
1.MainDatabase 使用Room实现的数据库,用于保存及加载Title
2.MainNetwork实现了一个网络API用于获取一个新的title。它使用Retrofit来获取titlesRetrofit被配置成随机返回错误或者mock数据,而不是发出了一个真的网络请求。
3.TitleRepository实现了一个单例API用于获取或者刷新title,通过将网络和数据库的数据做合并。
4.MainViewModel用于展示状态并处理事件。它将通知repository刷新title,当用户点击屏幕时。

由于网络请求是通过UI-event驱动的。并且我们想要通过它来启动一个协程,我们将在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)
       }
   })
}

该函数在用户每次点击屏幕时都会调用,这将引起repository刷新title,并写入新的title到数据库中。

回调实现将做如下几件事:

  • 查询开始之前,_spinner.value = true操作将会显示一个加载spinner
  • 当获取到结果后,_spinner.value = true操作将会清掉加载spinner
  • 如果发生错误,将通过snackbar显示,并清理掉spinner

注意 onCompleted回调没有传递title。由于我们将所有的title写入到Room 数据库中,UI将通过观察LiveData来更新当前的title。

在下面的协程版本中,我们将做同样的事。使用可观察数据源来自动保持UI的更新是一个很好的方式,例如Room数据库。

小提示:
object: TitleRefreshCallback是什么意思呢?
这是kotlin的匿名类写法,通过实现TitleRefreshCallback接口来创建一个匿名对象。

协程版本
我们将使用协程重写refreshTitle方法!

接下来,我们将在TitleRespository.kt写一个空的挂起函数。使用suspend定义一个新函数,用于告诉Kotlin将使用协程工作。

TitleRepository.kt

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

在该代码段中,我们将使用协程的方式 ,通过RetrofitRoom获取一个新的title并写入到数据库。现在,我们在这里做个500ms的延迟来模拟这些工作,然后继续:

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 {

和协程更新tap计数一样,我们在viewModelScope中启动一个协程。该协程将运行在Dispatchers.Main中。尽管refreshTitle将发起一个网络请求和一个数据库查询,在使用协程时将是main-safe的接口。这意味着从主线程调用将是安全的。

因为我们使用了viewModelScope,当用户销毁了界面,协程将被自动取消。从而不会再发出网络请求和数据库查询。

小贴士:
启动一个协程使用launch
该方式中,如果launch中抛出一个异常,将被自动传递给未捕获异常处理者(可以导致App crash)。使用async启动协程将不会抛出一个异常,只有当你调用await时才会抛出异常。然而,你只能在协程内部调用await,由于它是suspend函数。
一旦在一个协程内部,你可以使用launch或者async来启动一个子协程。当你不需要结果返回时使用launch,需要结果返回时使用async

接下来的代码,我们将在repository中调用refreshTitle函数。

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

在这个协程启动之前,将首先显示一个正在加载的spinner-然后正常调用refreshTitle。然而,由于refreshTitle是一个挂起函数,和普通函数执行有很大不同。

我们不需要传递一个callback。协程将被挂起直到refreshTitle调用后恢复。当它看起来像一个普通的阻塞函数调用,它将自动等待知道网络和数据库查询完成,而不会阻塞主线程。

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

异常的处理在suspend函数同普通函数一样。如果你在一个suspend函数中抛出一个错误,它将会抛给调用者。因此尽管他们的执行有很大的不同,你可以使用普通的try/catch代码块处理它们。
这是非常有用的,它允许你依赖内建语言支持来进行错误处理,而不用为每个回调建立单独的错误处理器。

在finally代码块中,我们可以确保spinner在查询后总是关闭的。

小贴士:
未捕获的异常将如何处理?
在协程中,未捕获的异常的处理同非协程代码块中相同。默认条件下,它们将取消协程中的Job,并通知父协程自动取消其Jobs。如果没有协程处理这些异常,它最后将传递给CoroutineScope中的一个未捕获异常处理器。

默认情况下,未捕获异常将发送到JVM中的未捕获异常处理器中处理。你可以自定义一个CoroutineExceptionHandler用来做异常处理。

8. 从阻塞代码块提炼线程安全函数

本节内容中,你将学习到如何切换一个协程运行的线程。

打开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.运行fetchNextTitle进行网络请求,并使用execute()方法进行阻塞调用。网络请求将在当前线程中运行,该场景下BACKGROUND线程。
3.如何结果成功,使用insertTitle保存到数据库中,并调用onCompleted()方法.
4.如何结果未成功,或者存在异常,则调用onError方法通知调用者刷新失败。

回调的实现是main-safe安全的,因为它不会阻塞主线程。但是,当任务完成时,它不得不使用回调来通知调用者。

从协程中进行阻塞调用

不需要向网络和数据库引入协程,我们可以使用协程让代码main-safe。这可以让我们摆脱回调并让我们回传结果到调用线程。你可以在任何时候使用这种方式,当你需要在一个协程内部做阻塞任务或者CPU密集型任务,例如排序、对一个大的列表过滤或者从磁盘读取数据。

小贴士:
这种方式应该通过阻塞API的方式集成或者执行CPU密集型工作。如果有可能,最好使用suspend函数,例如Room或者Retrofi都有提供。

在任何dispatcher间切换,协程使用withContext。调用withContext切换到其他dispatcher,然后携带结果切换回来,这个过程都是通过lambda方式完成的。

默认情况下,Kotlin协程提供了3种Dispatchers:MainIODefault.IO dispatcher是对IO操作的优化,例如从网络或者磁盘读取数据,Default dispatcher是对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)
       }
   }
}

该代码实现使用阻塞调用从网络或者数据库获取数据,想较于回调版本简单了些许。
该代码仍然使用了阻塞调用。调用excute()insertTitle(...)都将阻塞线程,该线程是协程运行所在。然后,使用witchContext来切换到Dispatchers.IO,我们将在某个IO线程中阻塞。协程可能运行在Dispatchers.Main中,调用将被挂起直到withContext lambda表达式完成调用。

同回调版本比较起来,有两点重要的不同:

  1. withContext将返回结果到Dispatcher,该场景下是Dispatchers.Main线程。回调版本中回调函数将在BACKGROUND后台线程调用。
  2. 调用者不需要给这个函数传递callback。他们可以通过suspendresume来获取结果或错误。

特别提示:
上面的代码没有支持协程取消,但是协程取消在Kotlin中是被支持的,协程取消支持。这就意味着你的代码在做协程取消操作时需要明确的检查,无论在何时你调用kotlin协程函数。

因为withContext代码块一旦被调用将不可被取消,直到有结果返回。

为了弥补这点,你可以调用yield来允许其他协程运行并进行取消检查。你可能会在网络请求和数据库查询中间加入一个yield调用。然后,在网络请求期间,协程被取消,数据将不会被保存到数据库中。你也可以检查明确取消,当你编写底层的协程接口时,你应该做明确的取消检查。

再次运行App
如果你再次运行该App,你将看到基于协程实现的效果,从网络加载数据。接下来,我们将在Room和Retrofit中集成协程。

9. Room&Retrofit中的协程

接下来我们将使用支持了suspend函数的稳定版本的Room和Retrofit,使用suspend函数的调用代码将是简单的顺序调用。

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并自动在后台线程中执行。然而,这也意味着你只能在一个协程中调用该查询。

在Retrofit中使用协程
接下来,让我看看如何在Retrofit中集成协程。打开MainNetwork.kt并将fetchNextTitle改写成suspend函数。将返回结果类型由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格式。如果你想返回完整的结果Result,你可以Result<String>作为返回结果类型。

小提示
Room和Retrofit的挂起函数都是main-safe的,从Dispatchers.Main调用这些挂起函数是安全的,尽管是从网络获取数据并写入到数据库。

提示
Room和Retrofit使用自定义的dispatcher,并没有使用Dispatchers.IO.
Room将使用默认的querytransactionExcutor来运行协程。Retrofit将在该线程创建一个新的Call对象,并调用enqueue发送异步请求。

使用Room和Retrofit

现在Room和Retrofi支持suspend函数,我们可以在repository中使用它们。打开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)
   }
}

代码非常简单。那么这是怎么回事呢?返回结果依赖于suspendresume让代码如此简洁。Retrofit让我们可以返回像String或者User对象这样的结果类型,而不是Call对象。这种调用是安全的,因为内部的suspend函数,Retrofit可以将网络请求运行在后台线程并在调用完成时恢复。

更好的一点是,我们摆脱了witchContext代码块。由于Room和Retrofit都提供了main-safe挂起函数,从Dispatchers.Main发起这些异步任务调用是非常安全的。

小提示
你不需要使用withContext调用main-safe挂起函数
通常,你需要确保suspend函数是main-safe。通过这种方式,从任何dispatcher调用是安全,包括在Dispatchers.Main中。

修复编译错误
改写成协程确实需要更改函数的签名,因为你不能从常规函数调用挂起函数。当你在这一步中添加suspend修饰符时,会有一些编译错误生成,用于告诉你如果将一个函数改写成suspend函数会发生什么。

遍历整个工程,修改这些编译错误。下面有一些快速解决办法:
TestingFakes.kt
更新这个测试类用于支持挂起。
TitleDaoFake
1.键入alt-enter(Mac电脑上键入option-enter)用于将这个类中的所有函数添加suspend修饰符。
MainNetworkFake
2. 键入alt-enter将该类中所有函数添加suspend修饰符
3.替换fetchNextTitle函数为如下代码

override suspend fun fetchNextTitle() = result

MainNetworkCompletableFake

  1. 键入alt-enter 为hierarchy中的所有函数添加suspend修饰符
  2. 替换fetchNextTitle为如下函数
override suspend fun fetchNextTitle() = completable.await()

TitleRepository.kt

  • 删除refreshTitleWithCallbacks函数

再次运行app
再次运行app,一旦编译完成,你将看到它将通过协程加载数据,从ViewModel到Room和Retrofit。

你已经完成了从app的协程改造! 接下来,我们将讲一点关于如何测试的问题。

10. 直接测试协程

接下来,我们将写一个测试case直接调用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文件,有两个待实现的TODOS.
尝试在第一个测试casewhenRefreshTitleSuccess_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.”

test runner不知道协程,所以这个test不能被标记为suspend函数。我们可以用CoroutineScopelaunch一个协程,就像在ViewModel中一样,然而tests需要在返回结果前跑完协程。一旦一个test函数返回,test将结束。launch启动的协程是异步代码,可能在未来的某个时间点完成。因此为了测试异步代码,你需要用一些方式告诉test等待,直到协程完成。由于launch是非阻塞调用,那就意味在函数调用返回之后,它将立即返回并且可以继续运行一个协程-所以launch将不可以用在tests中。例如:

@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调用将立即返回,并在剩余测试case执行时执行。这个test无法得知refreshTitle是否执行了-任何断言数据库更新等行为看起来是很奇怪。并且,如果refreshTitle抛出了一个异常,它将无法在test调用栈中打印。它将在GlobalScope中的未捕获异常处理器中抛出。

kotlinx-coroutines-test Lib有runBlockingTest函数,调用挂起函数时会被阻塞。当runBlockingTest调用一个suspend函数时或launches一个新的协程时,它将立即按照默认方式执行。你可以考虑将它作为一种转换方式,从挂起函数和协程转换成普通函数调用。

另外,runBlockingTest将再次向你抛出未捕获的异常。这将使测试变得容易,当一个协程正在抛出一个异常。

重要提示:
runBlockingTest函数将一直阻塞调用者,有点像普通函数调用。协程将在同一个线程中同步运行。你应该避免在你的app代码中调用runBlockingrunBlockingTest函数,并优先使用launch,可以立即返回。
runBlockingTest应该在某些特定场景下运行,如在一个测试控制管理器中执行协程,runBlocking可以用于为协程提供阻塞接口。

用一个协程实现一个测试

runBlockingTest代码块包裹refreshTitle调用,并从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是否被写入到了数据库中。

当这个test调用了runBlockingTest,它将阻塞直到协程运行完成。在runBlockingTest内部,当我们调用refreshTitle时。它将使用suspend和resume机制等待数据库行添加完成。
当测试协程完成之后,runBlockingTest将返回。

编写一个超时测试

我们想为网路请求添加一个超时机制。下面我们先写一个超时测试的实现。创建一个新的test:
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类进行测试,这是一个网络fake,用于挂起调用者直到测试继续运行。当refreshTitle尝试进行网络请求,它将一直挂起,因为我们想要测试超时。

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

运行这段测试将看到如下异常信息:

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

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

添加一个超时
打开文件TitleRepository,为网络获取添加5s超时。你可以使用witchTimeout函数完成这个操作:
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)
   }
}

运行相关测试项,你将看到所有测试都通过了!

下面的学习中,你将学习如何使用协程编写高阶函数。

提示:
runBlockingTest依赖TestCoroutineDispatcher来控制协程。
因此,在使用runBlockingTest时注入一个TestCoroutineDispatcher实例或者TestCoroutineScope实例是一个很不错的做法。这可以使协程成为单线程,并提供明确控制测试中所有协程的能力。

如果你不想改变协程的这个能力——例如在一个集成测试中-你可以用runBlocking来替代所有dispatchers的默认实现。

runBlockingTest是实验性的,当前还有bug,在协程切换线程时候会导致测试失败。最终的稳定版本将解决这个bug

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

在接下来的练习中,你将重构MainViewMode中的refreshTitle函数用于通用数据加载功能。这将教会你u如何使用协程编写高阶函数。

refreshTitle的当前实现中,我们可以创建一个通用的数据加载协程,总是显示spinner。这在代码库中是很有用的,比如从响应中加载数据成几个事件,你想要确保加载进度的spinner持续显示。

请看下面代码的实现,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
       }
   }
}

重要提示:
尽管我们在代码中仅使用了viewModelScope,一般来说,可以在任何有意义的地方添加一个scope。但是当它不再需要时,不要忘记取消它。
例如,你可以在RecyclerViewAdapter中声明一个执行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()
   }
}

通过将加载进度和显示错误的逻辑抽象化,我们将加载数据的代码简化的很多。
当每次需要加载实际的数据源和目标时,可以将这种模型推广到任何需要数据加载的场景。

我了建立这个抽象,launchDataLoad携带了一个block参数,该参数是一个suspend的lambda表达式。一个suspend lambda表达式让你可以调用suspend函数。这就是Kotlin使用launchrunBlocking来实现协程的方式。

// suspend lambda

block: suspend () -> Unit

suspend lambda表达式以suspend关键词开头。这个函数将指向并返回Unit类型的结果。

12.WorkManager配合协程使用

什么是WorkManager

WorkManager是Google官方出品的标准Lib,是Jetpack组件之一,用于处理后台任务,可以确保任务有机会执行,即使用户退出了app,任务仍然可以在后台在设定的条件下执行。

因为这个特性,对于那些必须要完成的任务,WorkManager是个很好的选择。

WorkManager的应用场景:

  • 上传Log
  • 过滤图片并保存图片
  • 周期性地从网络同步数据到本地

小提示
学习更多WorkManager的内容,请参考相关文档

WorkManager配合协程使用

WorkManager提供了ListenableWorker类的不同实现以应对不同应用场景。

最简单的Worker类允许我们使用WorkManager执行一些同步操作。然而,到目前为止,我们一直致力于将代码库转换为使用协程和挂起函数,使用WorkManager最好的方式是使用CoroutineWork类,这个类允许我们定义doWork()函数为挂起函数。

打开RefreshMainDataWork文件,它已经继承了CoroutineWorker类,你需要实现doWork方法。

在挂起函数doWork内部,调用refreshTitle()并返回合适的结果!

在你完成这些工作后,代码将看起来是如下形式:

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类不同的是,这个函数不会运行在Executor上,而Executor是在配置WorkManager时配置的,doWork()使用的是coroutineContext中的dispatcher(默认的是Dispatchers.Default)

测试CoroutineWorker
WorkManager提供了几种不同的方法来测试Worker类,学习这些测试框架,请参考文档

WorkManager v2.1引入了一系列新的API用于测试ListenableWorker类,接下来我们将使用这些新的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传入factory参数,从而我们可以注入fake network对象。

测试本身使用TestListenableWorkerBuilder来创建worker,从而可以调用startWork()方法。

13. 总结

通过本文,你已经学会了在你的app中使用协程的基本技能。

我们学习了:

  • 如何集成协程到你的Android app中,从UI和WorkManager Jobs到简单的异步编程,
  • 如何在ViewModel中使用协程从网络获取数据并保存的数据库中,而这个过程不会阻塞主线程。
  • 如何在ViewModel销毁时,取消所有的协程。

对于测试基于协程的代码,我们可以直接调用挂起函数来进行测试。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值