添加仓库和手动依赖项注入

1. 准备工作

简介

在上一个 Codelab 中,您学习了如何使用 API 服务让 ViewModel 从网络中检索火星照片的网址,从而掌握了如何从网络服务中获取数据。尽管这种方法行之有效且易于实现,但无法很好地随着应用的增长而进行调整,并且需要使用多个数据源。为了解决此问题,建议您按照 Android 架构的最佳实践,把界面层和数据层分开。

在此 Codelab 中,您会将 Mars Photos 应用重构为单独的界面层和数据层。您将了解如何实现仓库模式以及如何使用依赖项注入。依赖项注入可以创建更灵活的编码结构,有助于开发和测试。

前提条件

学习内容

  • 仓库模式
  • 依赖项注入

构建内容

  • 修改 Mars Photos 应用,将应用拆分为界面层和数据层。
  • 在拆分数据层时实现仓库模式。
  • 使用依赖项注入创建松散耦合的代码库。

所需条件

  • 一台安装了现代网络浏览器(如最新版 Chrome)的计算机

获取起始代码

首先,请下载起始代码:

或者,您也可以克隆该代码的 GitHub 代码库:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout repo-starter

您可以在 Mars Photos GitHub 代码库中浏览该代码。

2. 拆分界面层和数据层

为什么要使用不同的层?

将代码拆分为不同的层可让您的应用具备更高的可伸缩性、更加稳定可靠、更易于测试。为不同的层定义清晰的边界还有助于多个开发者更轻松地开发同一应用,而不会对彼此产生负面影响。

根据 Android 的推荐应用架构,应用应至少具有一个界面层和一个数据层。

在此 Codelab 中,您将重点关注数据层并做出一些更改,确保您的应用遵循推荐的最佳实践。

什么是数据层?

数据层负责应用的业务逻辑以及为应用寻源和保存数据。数据层使用单向数据流模式向界面层公开数据。数据可能来自多个来源,例如网络请求、本地数据库或设备上的文件。

一个应用甚至可能有多个数据源。当应用打开时,它会从设备上的本地数据库(第一个来源)检索数据。当应用运行时,它会向第二个来源发出网络请求以检索较新的数据。

通过将数据与界面代码放在不同的层中,您可以在代码的某一部分进行更改,而不会影响其他部分。这种方法属于关注点分离的设计原则。一段代码侧重于自身的关注点,并封装其内部运行,与其他代码相隔离。封装用于向代码的其他部分隐藏其内部运行。当一段代码需要与另一段代码进行交互时,就需要通过接口来实现。

界面层的关注点是显示所提供的数据。界面不再检索数据,因为这是数据层的关注点。

数据层由一个或多个仓库组成。仓库本身包含零个或多个数据源。

71d12ea3a6d3a43e.png

最佳实践要求应用为其所使用的每种数据源类型提供一个仓库。

在此 Codelab 中,应用有一个数据源,因此在您重构代码后,应用只有一个仓库。对于此应用,从互联网检索数据的仓库将完成数据源的职责。为此,它会向 API 发出网络请求。如果数据源编码比较复杂或添加了额外的数据源,则数据源职责会被封装在单独的数据源类中,而仓库将负责管理所有数据源。

什么是仓库?

通常,仓库类的作用包括:

  • 向应用的其余部分公开数据。
  • 集中管理数据更改。
  • 解决多个数据源之间的冲突。
  • 对应用其余部分的数据源进行抽象化处理。
  • 存放业务逻辑。

Mars Photos 应用只有一个数据源,即网络 API 调用。它只检索数据,因此没有任何业务逻辑。数据通过仓库类公开提供给应用,该类会对数据源进行抽象化处理。

ff7a7cd039402747.png

3. 创建数据层

首先,您需要创建仓库类。Android 开发者指南指出,仓库类以其所负责的数据命名。仓库命名惯例数据类型 + 仓库。在您的应用中,其名称为 MarsPhotosRepository

创建仓库

  1. 右键点击 com.example.marsphotos,然后依次选择 New > Package
  2. 在对话框中,输入 data
  3. 右键点击 data 软件包,然后依次选择 New > Kotlin Class/File
  4. 在对话框中,选择 Interface,然后输入 MarsPhotosRepository 作为接口的名称。
  5. MarsPhotosRepository 接口内,添加一个名为 getMarsPhotos() 的抽象函数,该函数会返回 MarsPhoto 对象的列表。系统将通过协程调用它,因此请使用 suspend 进行声明。
import com.example.marsphotos.model.MarsPhoto

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}
  1. 在接口声明下,创建一个名称为 NetworkMarsPhotosRepository 的类来实现 MarsPhotosRepository 接口。
  2. 将接口 MarsPhotosRepository 添加到类声明中。

由于您未替换接口的抽象方法,因此系统会显示错误消息。下一步将解决此错误。

显示 MarsPhotosRepository 界面和 NetworkMarsPhotosRepository 类的 Android Studio 屏幕截图

  1. NetworkMarsPhotosRepository 类中,替换抽象函数 getMarsPhotos()。此函数通过调用 MarsApi.retrofitService.getPhotos() 返回数据。
import com.example.marsphotos.network.MarsApi

class NetworkMarsPhotosRepository() : MarsPhotosRepository {
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return MarsApi.retrofitService.getPhotos()
   }
}

接下来,您需要更新 ViewModel 代码,以便按照 Android 最佳实践,使用仓库来获取数据。

  1. 打开 ui/screens/MarsViewModel.kt 文件。
  2. 向下滚动到 getMarsPhotos() 方法。
  3. 将“val listResult = MarsApi.retrofitService.getPhotos()”行替换为以下代码:
import com.example.marsphotos.data.NetworkMarsPhotosRepository

val marsPhotosRepository = NetworkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()

5313985852c151aa.png

  1. 运行应用。请注意,显示的结果与之前的结果相同。

仓库将提供数据,而不是由 ViewModel 直接发出关于数据的网络请求。ViewModel 不再直接引用 MarsApi 代码。展示之前如何直接从 Viewmodel 访问数据层的流程图。我们现在拥有火星照片库

此方法有助于让代码检索与 ViewModel 松散耦合的数据。通过松散耦合,您可以对 ViewModel 或仓库进行更改,而不会对其他部分产生不利影响,只要仓库具有名为 getMarsPhotos() 的函数即可。

现在,我们可以在不影响调用方的情况下更改仓库内的实现。对于大型应用,此变更可支持多个调用方。

4. 依赖项注入

很多时候,类都需要其他类的对象才能正常运行。如果某个类需要另一个类,则所需的类称为依赖项

在以下示例中,Car 对象依赖于 Engine 对象。

类可通过两种方法获取这些所需对象。一种方法是让类实例化所需的对象本身。

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car {

    private val engine = GasEngine()

    fun start() {
        engine.start()
    }
}

fun main() {
    val car = Car()
    car.start()
}

另一种方法是将所需的对象作为实参传入。

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = GasEngine()
    val car = Car(engine)
    car.start()
}

让类实例化所需的对象非常简单,但这种方法会导致代码不够灵活且更加难以测试,因为类与所需对象是紧密耦合的。

调用类需要调用对象的构造函数,其中包含实现细节。如果构造函数发生更改,调用代码也需要做出更改。

为了提升代码的灵活性和适应性,类不得对其所依赖的对象进行实例化。它所依赖的对象必须在类外部进行实例化,然后再传入。这种方法可以创建更灵活的代码,因为类不再硬编码到特定对象中。可以对所需对象的实现进行更改,而无需修改调用代码。

继续前面的示例,如果您需要 ElectricEngine,则可以创建并将其传入 Car 类。不需要通过任何方式对 Car 类进行修改。

interface Engine {
    fun start()
}

class ElectricEngine : Engine {
    override fun start() {
        println("ElectricEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = ElectricEngine()
    val car = Car(engine)
    car.start()
}

传入所需对象的过程称为依赖项注入 (DI)。这种方法也称为控制反转

依赖项注入是指在运行时提供依赖项,而不是将其硬编码到调用类中。

实现依赖项注入:

  • 有助于提高代码的可重用性。代码不依赖于特定对象,从而提高灵活性。
  • 使重构更轻松。代码是松散耦合的,因此重构一段代码不会影响另一段代码。
  • 有助于进行测试。可以在测试期间传入测试对象。

例如,在测试网络调用代码时,DI 有助于进行测试。在此测试中,您实际上将尝试测试是否进行了网络调用以及是否返回了数据。在测试期间,如果您每次发出网络请求都有开销,则可能会决定跳过测试此代码,因为这样的成本可能会很高。现在,假设我们能否伪造用于测试的网络请求。这会让您感觉更好吗?对于测试,您可以将测试对象传递给在调用时返回虚构数据的仓库,而无需实际执行实际的网络调用。1ea410d6670b7670.png

我们希望能够对 ViewModel 进行测试,但它目前依赖于进行实际网络调用的仓库。使用真实的生产仓库进行测试时,系统进行许多网络调用。若要解决此问题,我们需要能够决定并传递用于动态生产和测试的仓库实例,而不是由 ViewModel 来创建仓库。

实现此流程的方法是实现一个为 MarsViewModel 提供仓库的应用容器。

容器是一个包含应用所需的依赖项的对象。这些依赖项在整个应用中使用,因此它们必须位于所有 activity 都可以使用的通用位置。您可以创建 Application 类的子类并存储对容器的引用。

创建应用容器

  1. 右键点击 data 软件包,然后依次选择 New > Kotlin Class/File
  2. 在对话框中,选择 Interface,然后输入 AppContainer 作为接口的名称。
  3. AppContainer 接口内,添加一个名为 marsPhotosRepository 且类型为 MarsPhotosRepository 的抽象属性。

a47a6b9d9cc5df58.png

  1. 在接口定义下,创建一个名为 DefaultAppContainer 的类来实现 AppContainer 接口。
  2. network/MarsApiService.ktBASE_URLretrofitretrofitService 变量的代码移至 DefaultAppContainer 类,让它们都位于用于维护依赖项的容器中。
import retrofit2.Retrofit
import com.example.marsphotos.network.MarsApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType

class DefaultAppContainer : AppContainer {

    private const val BASE_URL =
        "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit: Retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(BASE_URL)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

}
  1. 对于变量 BASE_URL,请移除 const 关键字。您必须移除 const,因为 BASE_URL 现在不再是顶级变量,而已成为 DefaultAppContainer 类的属性。将其重构为使用驼峰命名法的 baseUrl
  2. 对于变量 retrofitService,请添加 private 可见性修饰符。之所以添加 private 修饰符,是因为变量 retrofitService 仅在类中使用(通过属性 marsPhotosRepository),因此无需在类的外部访问。
  3. DefaultAppContainer 类会实现 AppContainer 接口,因此我们需要替换 marsPhotosRepository 属性。请在变量 retrofitService 后面添加以下代码:
override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetworkMarsPhotosRepository(retrofitService)
}

完成的 DefaultAppContainer 类应如下所示:

class DefaultAppContainer : AppContainer {

    private val baseUrl =
        "https://android-kotlin-fun-mars-server.appspot.com"

    /**
     * Use the Retrofit builder to build a retrofit object using a kotlinx.serialization converter
     */
    private val retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}
  1. 打开 data/MarsPhotosRepository.kt 文件。现在,我们将 retrofitService 传递给 NetworkMarsPhotosRepository,并且您需要修改 NetworkMarsPhotosRepository 类。
  2. NetworkMarsPhotosRepository 类声明中,添加构造函数形参 marsApiService,如以下代码所示。
import com.example.marsphotos.network.MarsApiService

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
  1. NetworkMarsPhotosRepository 类的 getMarsPhotos() 函数中,更改 return 语句以从 marsApiService 检索数据。
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
  1. MarsPhotosRepository.kt 文件中移除以下导入内容。
// Remove
import com.example.marsphotos.network.MarsApi

我们已经将 network/MarsApiService.kt 文件中的所有代码移出了对象。现在,我们可以删除不再需要的对象声明。

  1. 删除以下代码:
object MarsApi {

}

5. 将应用容器附加到应用

本部分中的步骤会将应用对象连接到应用容器,如下图所示。

6ff9e55cfa8f23e4.png

  1. 右键点击 com.example.marsphotos,然后依次选择 New > Kotlin Class/File
  2. 在对话框中,输入 MarsPhotosApplication。此类继承自应用对象,因此您需要将其添加到类声明中。
import android.app.Application

class MarsPhotosApplication : Application() {
}
  1. MarsPhotosApplication 类中,声明一个名为 container 且类型为 AppContainer 的变量,用于存储 DefaultAppContainer 对象。该变量会在调用 onCreate() 期间初始化,因此需要使用 lateinit 修饰符标记该变量。
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

lateinit var container: AppContainer
override fun onCreate() {
    super.onCreate()
    container = DefaultAppContainer()
}
  1. 完整的 MarsPhotosApplication.kt 文件应如下所示:
package com.example.marsphotos

import android.app.Application
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}
  1. 您需要更新 Android 清单,以便应用使用您刚刚定义的应用类。打开 manifests/AndroidManifest.xml 文件。

2ca07697492c53c5.png

  1. application 部分,添加值为应用类名称 ".MarsPhotosApplication"android:name 属性。
<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

6. 将仓库添加到 ViewModel

完成这些步骤后,ViewModel 可以调用仓库对象来检索火星数据。

7425864315cb5e6f.png

  1. 打开 ui/screens/MarsViewModel.kt 文件。
  2. MarsViewModel 的类声明中,添加一个类型为 MarsPhotosRepository 的私有构造函数形参 marsPhotosRepository。构造函数形参的值来自应用容器,因为应用现在在使用依赖项注入。
import com.example.marsphotos.data.MarsPhotosRepository

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
  1. getMarsPhotos() 函数中,移除以下代码行,因为现在构造函数调用中填充了 marsPhotosRepository
val marsPhotosRepository = NetworkMarsPhotosRepository()
  1. 由于 Android 框架不允许在创建时向构造函数中的 ViewModel 传递值,因此我们实现了一个 ViewModelProvider.Factory 对象来绕过此限制。

工厂模式是用于创建对象的创建模式。MarsViewModel.Factory 对象使用应用容器检索 marsPhotosRepository,然后在创建 ViewModel 对象时将此仓库传递给 ViewModel

  1. 在函数 getMarsPhotos() 下,输入伴生对象的代码。

有了伴生对象,我们便有了一个可供所有人使用的单一对象实例,而无需创建新的对象实例,从而避免增加开销。这是实现细节。在对其进行分离之后,我们可以轻松做出更改,而不会影响应用代码的其他部分。

APPLICATION_KEYViewModelProvider.AndroidViewModelFactory.Companion 对象的一部分,用于查找应用的 MarsPhotosApplication 对象,该对象具有 container 属性,用于检索依赖项注入所使用的仓库。

import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.marsphotos.MarsPhotosApplication

companion object {
   val Factory: ViewModelProvider.Factory = viewModelFactory {
       initializer {
           val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
           val marsPhotosRepository = application.container.marsPhotosRepository
           MarsViewModel(marsPhotosRepository = marsPhotosRepository)
       }
   }
}
  1. 打开 theme/MarsPhotosApp.kt 文件,在 MarsPhotosApp() 函数内,更新 viewModel() 以使用工厂。
Surface(
            // ...
        ) {
            val marsViewModel: MarsViewModel =
   viewModel(factory = MarsViewModel.Factory)
            // ...
        }

marsViewModel 变量由对 viewModel() 函数的调用进行填充,该函数会将伴生对象中的 MarsViewModel.Factory 作为实参传递以创建 ViewModel

  1. 运行应用,确认应用是否像之前一样正常运行。

恭喜您重构了 Mars Photos 应用,它现已使用仓库和依赖项注入!通过仓库实现数据层之后,界面和数据源已实现分离,并且符合 Android 最佳实践。

通过使用依赖项注入,您可以更轻松地测试 ViewModel。您的应用现已具备更高的灵活性、稳健性和可扩缩性。

完成这些改进后,接下来学习如何对它们进行测试。测试可确保您的代码按预期运行,并降低在继续编写代码时引入 bug 的可能性。

7. 设置本地测试

在前面的部分中,您已经实现了一个仓库,用于从 ViewModel 中抽象出与 REST API 服务的直接交互。通过这种方式,您可以测试用途有限的小段代码。针对功能有限的小段代码进行测试,要比针对具有多种功能的大段代码编写的测试更容易构建、实现和理解。

此外,您还利用接口、继承和依赖项注入实现了仓库。在接下来的部分中,您将了解这些架构最佳实践为何能够让测试变得更加简单易行。此外,您还使用 Kotlin 协程发出了网络请求。在测试使用协程的代码时,您需要执行额外的步骤来考虑异步执行代码的问题。此 Codelab 稍后将介绍这些步骤。

添加本地测试依赖项

将以下依赖项添加到 app/build.gradle.kts

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")

创建本地测试目录

  1. 创建一个本地测试目录,具体方法为:右键点击项目视图中的 src 目录,然后依次选择 New > Directory > test/java
  2. 在该测试目录中创建一个名为 com.example.marsphotos 的新软件包。

8. 为测试创建虚构数据和依赖项

在本部分中,您将了解依赖项注入如何帮助您编写本地测试。在此 Codelab 前面的部分中,您创建了一个依赖于 API 服务的仓库。然后,您修改了 ViewModel,使其依赖于仓库。

每个本地测试只需测试一项内容。例如,在测试视图模型的功能时,您不希望测试仓库或 API 服务的功能。同样,在测试仓库时,您不希望测试 API 服务。

通过使用接口并随后使用依赖项注入来包含从这些接口继承的类,您可以使用仅用于测试用途的虚构类来模拟这些依赖项的功能。通过注入虚构类和数据源来进行测试,您可以在隔离环境中单独测试代码,同时确保代码的可重复性和一致性。

首先,您需要在稍后创建的虚构类中使用的虚构数据。

  1. 在测试目录中,在 com.example.marsphotos 下创建一个名为 fake 的软件包。
  2. fake 目录中创建一个名为 FakeDataSource 的新 Kotlin 对象。
  3. 在此对象中,创建一个设置为 MarsPhoto 对象列表的属性。该列表不需要太长,但至少应包含两个对象。
object FakeDataSource {

   const val idOne = "img1"
   const val idTwo = "img2"
   const val imgOne = "url.1"
   const val imgTwo = "url.2"
   val photosList = listOf(
       MarsPhoto(
           id = idOne,
           imgSrc = imgOne
       ),
       MarsPhoto(
           id = idTwo,
           imgSrc = imgTwo
       )
   )
}

在此 Codelab 的前面部分中,我们在讨论依赖于 API 服务的仓库时也提到了这一点。要创建仓库测试,必须通过一个虚构 API 服务来返回您刚才创建的虚构数据。将此虚构 API 服务传入仓库之后,仓库会在系统调用虚构 API 服务中的方法时收到虚构数据。

  1. fake 软件包中,新建一个名为 FakeMarsApiService 的类。
  2. 设置 FakeMarsApiService 类,让其从 MarsApiService 接口继承。
class FakeMarsApiService : MarsApiService {
}
  1. 替换 getPhotos() 函数。
override suspend fun getPhotos(): List<MarsPhoto> {
}
  1. getPhotos() 方法返回虚构照片列表。
override suspend fun getPhotos(): List<MarsPhoto> {
   return FakeDataSource.photosList
}

请记住,如果您仍不清楚此类的用途,也没有关系!下一部分将详细介绍此虚构类的作用。

9. 编写仓库测试

在本部分中,您将测试 NetworkMarsPhotosRepository 类的 getMarsPhotos() 方法。本部分介绍了虚构类的用法,并演示了如何测试协程。

  1. 在虚构目录中,新建一个名为 NetworkMarsRepositoryTest 的类。
  2. 在您刚才创建的类中新建一个名为 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 的方法,并为其添加 @Test 注解。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}

如需测试该仓库,您需要一个 NetworkMarsPhotosRepository 实例。回想一下,此类依赖于 MarsApiService 接口。您可以在此处利用上一部分中的虚构 API 服务。

  1. 创建一个 NetworkMarsPhotosRepository 实例并将 FakeMarsApiService 作为 marsApiService 形参传递。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )
}

通过传递虚构 API 服务,对仓库中的 marsApiService 属性的任何调用都会导致调用 FakeMarsApiService。通过为依赖项传递虚构类,您可以精确控制依赖项返回的结果。这种方法可以确保您测试的代码不依赖于可能未经测试的代码或 API,因为这些代码或 API 可能会发生变化或出现意外问题。即使您所编写的代码没有任何问题,此类情况也可能会导致测试失败。虚构测试有助于创建更一致的测试环境、减少测试不稳定问题,以及简化针对单一功能的测试。

  1. 断言 getMarsPhotos() 方法返回的数据等于 FakeDataSource.photosList
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}

请注意,在 IDE 中,getMarsPhotos() 方法调用带有红色下划线。

2bd5f8999e0f3ec2.png

如果将鼠标悬停在该方法上,您会看到“Suspend function ‘getMarsPhotos' should be called only from a coroutine or another suspend function”这条提示:

d2d3b6d770677ef6.png

data/MarsPhotosRepository.kt 中,查看 NetworkMarsPhotosRepository 中的 getMarsPhotos() 实现,您会看到 getMarsPhotos() 函数是一个挂起函数。

class NetworkMarsPhotosRepository(
   private val marsApiService: MarsApiService
) : MarsPhotosRepository {
   /** Fetches list of MarsPhoto from marsApi*/
   override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}

请记住,当您从 MarsViewModel 调用此函数时,系统将通过一个协程来调用此方法,即通过传递到 viewModelScope.launch() 的 lambda 来进行调用。此外,您还需要在测试中通过协程来调用挂起函数,例如 getMarsPhotos()。只不过方法有所不同。下一部分将介绍如何解决此问题。

测试协程

在本部分中,您将修改 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 测试,以便从协程运行测试方法的主体部分。

  1. NetworkMarsRepositoryTest.kt 中,将 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 函数修改为表达式。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
  1. 将表达式设置为等于 runTest() 函数。此方法需要 lambda。
...
import kotlinx.coroutines.test.runTest
...

@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
    runTest {}

协程测试库提供 runTest() 函数。该函数接受您在 lambda 中传递的方法,并通过 TestScope(从 CoroutineScope 继承而来)运行该方法。

  1. 将测试函数的内容移至 lambda 函数。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
   runTest {
       val repository = NetworkMarsPhotosRepository(
           marsApiService = FakeMarsApiService()
       )
       assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
   }

请注意,getMarsPhotos() 下的红线现已消失。如果运行此测试,测试将会通过!

10. 编写 ViewModel 测试

在本部分中,您将通过 MarsViewModelgetMarsPhotos() 函数编写一项测试。MarsViewModel 依赖于 MarsPhotosRepository。因此,如需编写此测试,您需要创建一个虚构 MarsPhotosRepository。此外,除了使用 runTest() 方法之外,还需要为协程考虑一些额外的步骤。

创建虚构仓库

此步骤的目的是创建一个从 MarsPhotosRepository 接口继承的虚构类,并替换 getMarsPhotos() 函数以返回虚构数据。此方法类似于您在创建虚构 API 服务时使用的方法,不同之处在于此类会扩展 MarsPhotosRepository 接口,而不是 MarsApiService

  1. fake 目录中创建一个名为 FakeNetworkMarsPhotosRepository 的新类。
  2. 使用 MarsPhotosRepository 接口扩展此类。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
  1. 替换 getMarsPhotos() 函数。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
   }
}
  1. getMarsPhotos() 函数返回 FakeDataSource.photosList
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return FakeDataSource.photosList
   }
}

编写 ViewModel 测试

  1. 创建一个名为 MarsViewModelTest 的新类。
  2. 创建一个名为 marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() 的函数,并为其添加 @Test 注解。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
  1. 将此函数设为表达式并获取 runTest() 方法的结果,以确保从协程运行测试,就像上一部分中的仓库测试一样。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
    }
  1. runTest() 的 lambda 主体部分中,创建一个 MarsViewModel 实例并向其传递您所创建的虚构仓库的实例。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
        val marsViewModel = MarsViewModel(
            marsPhotosRepository = FakeNetworkMarsPhotosRepository()
         )
    }
  1. 断言 ViewModel 实例的 marsUiState 与成功调用 MarsPhotosRepository.getMarsPhotos() 的结果相匹配。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
   runTest {
       val marsViewModel = MarsViewModel(
           marsPhotosRepository = FakeNetworkMarsPhotosRepository()
       )
       assertEquals(
           MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
                   "photos retrieved"),
           marsViewModel.marsUiState
       )
   }

如果您尝试按原样运行此测试,测试将失败。错误如以下示例所示:

Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

回想一下,MarsViewModel 使用 viewModelScope.launch() 调用仓库。此指令会在默认协程调度程序(称为 Main 调度程序)下启动一个新的协程。Main 调度程序会封装 Android 界面线程。出现此错误的原因是 Android 界面线程在单元测试中不可用。单元测试是在工作站(而非 Android 设备或模拟器)上执行的。如果本地单元测试下的代码引用 Main 调度程序,则在运行单元测试时会抛出异常(如上所示)。为解决此问题,您需要在运行单元测试时明确定义默认调度程序。请继续阅读下一部分,了解如何实现此过程。

创建测试调度程序

由于 Main 调度程序仅适用于界面上下文,因此您必须将其替换为支持单元测试的调度程序。Kotlin 协程库为此提供了一个名为 TestDispatcher 的协程调度程序。在任何创建新协程的单元测试中,都需要使用 TestDispatcher(而非 Main 调度程序),类似于视图模型中的 getMarsPhotos() 函数。

如需在任何情况下将 Main 调度程序替换为 TestDispatcher,请使用 Dispatchers.setMain() 函数。您可以使用 Dispatchers.resetMain() 函数将线程调度程序重置为 Main 调度程序。为避免在每项测试中重复使用替换 Main 调度程序的代码,您可以将其提取到 JUnit 测试规则中。TestRule 提供了一种控制运行测试的环境的方法。TestRule 可能会添加其他检查,对测试执行必要的设置或清理,或者观察测试作业以向其他位置报告。可以在测试类之间轻松共享这些测试规则。

创建一个专有类来编写 TestRule 以替换 Main 调度程序。如需实现自定义 TestRule,请完成以下步骤:

  1. 在测试目录中创建一个名为 rules 的新软件包。
  2. 在 rules 目录中,新建一个名为 TestDispatcherRule 的类。
  3. 使用 TestWatcher 扩展 TestDispatcherRule。借助 TestWatcher 类,您可以在测试的不同执行阶段执行操作。
class TestDispatcherRule(): TestWatcher(){

}
  1. TestDispatcherRule 创建一个 TestDispatcher 构造函数形参。

此形参可让您使用不同的调度程序,例如 StandardTestDispatcher。此构造函数形参需要将默认值设置为 UnconfinedTestDispatcher 对象的实例。UnconfinedTestDispatcher 类继承自 TestDispatcher 类,并指定不得以任何特定顺序执行任务。这种执行模式适用于简单的测试,因为系统会自动处理协程。与 UnconfinedTestDispatcher 不同,StandardTestDispatcher 类可以支持对协程执行进行全面的控制。这种方法更适合需要手动方法的复杂测试,但此 Codelab 中的测试并不需要这样做。

class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

}
  1. 此测试规则的主要目标是在测试开始执行之前将 Main 调度程序替换为测试调度程序。TestWatcher 类的 starting() 函数会在执行特定测试之前执行。替换 starting() 函数。
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {

    }
}
  1. 添加对 Dispatchers.setMain() 的调用,并传入 testDispatcher 作为实参。
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
}
  1. 在测试执行完成后,替换 finished() 方法以重置 Main 调度程序。调用 Dispatchers.resetMain() 函数。
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

TestDispatcherRule 规则已可供重复使用。

  1. 打开 MarsViewModelTest.kt 文件。
  2. MarsViewModelTest 类中,对 TestDispatcherRule 类进行实例化并将其分配给 testDispatcher 只读属性。
class MarsViewModelTest {

    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 如需将此规则应用于测试,请将 @get:Rule 注解添加到 testDispatcher 属性。
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 重新运行测试。确认测试这次成功通过。

11. 获取解决方案代码

如需下载完成后的 Codelab 代码,可以使用以下命令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout coil-starter

或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

如果您想查看此 Codelab 的解决方案代码,请前往 GitHub 查看。

12. 总结

恭喜您完成此 Codelab 并重构了 Mars Photos 应用,以实现仓库模式和依赖项注入!

应用的代码现已遵循有关数据层的 Android 最佳实践,这意味着应用更加灵活、可靠且易于伸缩。

这些更改还有助于让该应用更易于测试。这项优势非常重要,因为代码可以继续迭代,同时确保其行为符合预期。

别忘了使用 #AndroidBasics 标签在社交媒体上分享您的作品!

13. 了解更多内容

Android 开发者文档:

其他: