添加存储库和手动 DI

1. 在您开始之前

简介

在之前的代码实验室中,您学习了如何通过让 ViewModel 使用 API 服务从网络检索火星照片的 URL 来获取来自 Web 服务的数据。虽然这种方法有效且易于实现,但当您的应用程序不断发展并需要与多个数据源进行交互时,这种方法的扩展性不佳。为了解决这个问题,Android 架构最佳实践建议将 UI 层和数据层分开。

在本代码实验室中,您将对 火星照片 应用程序进行重构,将其分为单独的 UI 层和数据层。您将学习如何实现存储库模式并使用依赖项注入。依赖项注入创建了更灵活的编码结构,有助于开发和测试。

先决条件

您将学到的内容

  • 存储库模式
  • 依赖项注入

您将构建的内容

  • 修改 火星照片 应用程序,将其分为 UI 层和数据层。
  • 在分离数据层的同时,您将实现存储库模式。
  • 使用依赖项注入创建松散耦合的代码库。

您需要的内容

  • 一台装有现代 Web 浏览器的计算机,例如最新版本的 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

您可以在 火星照片 GitHub 存储库中浏览代码。

2. 分离 UI 层和数据层

为什么要使用不同的层?

将代码分离到不同的层可以使您的应用程序更具可扩展性、更健壮,并且更易于测试。具有明确界定的边界的多个层还可以使多个开发人员更轻松地协同处理同一个应用程序,而不会对彼此造成负面影响。

Android 的推荐应用程序架构 指出,应用程序应该至少具有 UI 层和数据层。

在本代码实验室中,您将专注于数据层并进行更改,使您的应用程序遵循推荐的最佳实践。

什么是数据层?

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

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

通过将数据放在与 UI 代码分开的层中,您可以对代码的一部分进行更改,而不会影响另一部分。这种方法是设计原则 关注点分离 的一部分。代码的一部分专注于它自己的关注点,并将其实现细节封装起来,使其不会影响其他代码。封装是一种从其他代码部分隐藏代码内部工作原理的方式。当代码的一部分需要与另一部分代码交互时,它会通过接口进行交互。

UI 层的关注点是显示提供给它的数据。UI 不再检索数据,因为这是数据层的关注点。

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

dbf927072d3070f0.png

最佳实践要求应用程序为应用程序使用的每种数据源类型创建一个存储库。

在本代码实验室中,应用程序只有一个数据源,因此在您重构代码后,它只有一个存储库。对于此应用程序,从互联网检索数据的存储库完成了数据源的职责。它通过向 API 发出网络请求来实现这一点。如果数据源编码更复杂或添加了更多数据源,数据源职责将封装在单独的数据源类中,存储库负责管理所有数据源。

什么是存储库?

一般来说,存储库类

  • 向应用程序的其他部分公开数据。
  • 集中管理对数据的更改。
  • 解决多个数据源之间的冲突。
  • 从应用程序的其他部分抽象数据源。
  • 包含业务逻辑。

火星照片 应用程序只有一个数据源,即网络 API 调用。它没有任何业务逻辑,因为它只检索数据。数据通过存储库类公开给应用程序,存储库类抽象了数据源。

ff7a7cd039402747.png

3. 创建数据层

首先,您需要创建存储库类。Android 开发者指南指出,存储库类以它们负责的数据类型命名。 存储库命名约定数据类型 + 存储库。在您的应用程序中,它是 MarsPhotosRepository

创建存储库

  1. 右键单击 com.example.marsphotos,然后选择 新建 > 包
  2. 在对话框中,输入 data
  3. 右键单击 data 包,然后选择 新建 > Kotlin 类/文件。
  4. 在对话框中,选择 接口,并将 MarsPhotosRepository 作为接口的名称输入。
  5. MarsPhotosRepository 接口内部,添加一个名为 getMarsPhotos() 的抽象函数,该函数返回一个 MarsPhoto 对象列表。它从协程中调用,因此使用 suspend 声明它。
import com.example.marsphotos.model.MarsPhoto

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

由于您没有覆盖接口的抽象方法,因此会显示一条错误消息。下一步将解决此错误。

Android studio screenshot showing MarsPhotosRepository interface and  class NetworkMarsPhotosRepository

  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 是在运行时提供依赖项,而不是将依赖项硬编码到调用类中。

实现依赖项注入

  • 有助于代码的可重用性。 代码不依赖于特定对象,这使得代码更具灵活性。
  • 使重构更容易。 代码是松散耦合的,因此重构代码的一部分不会影响代码的另一部分。
  • 有助于测试。 测试对象可以在测试期间传递进来。

依赖注入 (DI) 在测试网络调用代码时非常有用。在测试中,你实际上是要测试网络调用是否成功,以及是否返回了数据。如果你每次在测试期间进行网络请求都要付费,你可能会选择跳过测试这段代码,因为这会很昂贵。现在,想象一下,如果我们可以模拟网络请求进行测试,你会不会感到更开心(以及更富有)?在测试中,你可以将一个测试对象传递给仓库,当调用时,该对象会返回模拟数据,而不会实际执行真实的网络调用。 1ea410d6670b7670.png

我们希望使 ViewModel 可测试,但它目前依赖于一个进行实际网络调用的仓库。在使用真实的生产仓库进行测试时,它会进行许多网络调用。为了解决这个问题,我们不应让 ViewModel 创建仓库,而是需要一种方法来动态决定和传递一个要用于生产和测试的仓库实例。

这个过程通过实现一个应用程序容器来完成,该容器为 MarsViewModel 提供仓库。

一个 容器 是一个包含应用程序所需的依赖项的对象。这些依赖项在整个应用程序中使用,因此它们需要在一个所有活动都可以使用的公共位置。你可以创建一个 Application 类的子类,并在其中存储对容器的引用。

创建应用程序容器

  1. 右键单击 data 包,然后选择“新建”>“Kotlin 类/文件”。
  2. 在对话框中,选择“接口”,并将接口名称输入为 AppContainer
  3. AppContainer 接口中,添加一个名为 marsPhotosRepository 的抽象属性,其类型为 MarsPhotosRepository7ed26c6dcf607a55.png
  4. 在接口定义下方,创建一个名为 DefaultAppContainer 的类,该类实现接口 AppContainer
  5. network/MarsApiService.kt 中,将变量 BASE_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() 中,将返回值更改为从 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. 将应用程序容器附加到应用程序

本节中的步骤将应用程序对象连接到应用程序容器,如下图所示。

92e7d7b79c4134f0.png

  1. 右键单击 com.example.marsphotos,然后选择“新建”>“Kotlin 类/文件”。
  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 文件。

759144e4e0634ed8.png

  1. application 部分中,添加 android:name 属性,其值为应用程序类名 ".MarsPhotosApplication"
<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. 运行应用程序以确认它仍然像以前一样运行。

恭喜你将 火星照片 应用程序重构为使用仓库和依赖注入!通过使用带有仓库的数据层,UI 和数据源代码已分离以遵循 Android 最佳实践。

通过使用依赖注入,可以更容易地测试 ViewModel。你的应用程序现在更加灵活、健壮,并已准备好扩展。

完成这些改进后,现在是学习如何测试它们的时候了。测试可以确保你的代码按预期运行,并减少在你继续处理代码时引入错误的可能性。

7. 为本地测试做好准备

在前面的部分中,你已经实现了一个仓库,以将与 REST API 服务的直接交互抽象到 ViewModel 之外。这种做法可以让你测试具有有限目的的小段代码。测试具有有限功能的小段代码比针对具有多个功能的大段代码编写的测试更容易构建、实现和理解。

你还在实现仓库时利用了接口、继承和依赖注入。在接下来的部分中,你将了解这些架构最佳实践为什么使测试更容易。此外,你已经使用 Kotlin 协程来进行网络请求。测试使用协程的代码需要额外的步骤来考虑代码的异步执行。这些步骤将在本代码实验室的后面部分介绍。

添加本地测试依赖项

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

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

创建本地测试目录

  1. 在项目视图中右键单击 **src** 目录,然后选择 **新建 > 目录 > test/java**,以创建本地测试目录。
  2. 在测试目录中创建一个名为 com.example.marsphotos 的新包。

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

在本节中,您将学习依赖注入如何帮助您编写本地测试。在之前的代码实验室中,您创建了一个依赖于 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
       )
   )
}

之前在本代码实验室中提到过,资源库依赖于 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。此类情况会导致测试失败,即使您编写的代码没有错误。假数据有助于创建更一致的测试环境,减少测试不稳定性,并促进测试单个功能的简洁测试。

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

请注意,在您的 IDE 中,getMarsPhotos() 方法调用用红色下划线标出。

2bd5f8999e0f3ec2.png

如果您将鼠标悬停在该方法上,您会看到一个提示,指示“挂起函数 ‘getMarsPhotos' 只能从协程或其他挂起函数调用:”

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 测试

在本节中,您将为 MarsViewModel 中的 getMarsPhotos() 函数编写测试。 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 UI 线程。前面错误的原因是,Android UI 线程在单元测试中不可用。单元测试在您的工作站上执行,而不是在 Android 设备或模拟器上。如果本地单元测试下的代码引用了 Main 调度程序,则在运行单元测试时会抛出异常(如上面的异常)。要克服此问题,您必须在运行单元测试时显式定义默认调度程序。前往下一节,了解如何执行此操作。

创建测试调度程序

由于 Main 调度程序仅在 UI 上下文中可用,因此您必须用一个对单元测试友好的调度程序替换它。Kotlin 协程库为此目的提供了一个协程调度程序,称为 TestDispatcher。在任何创建新协程的单元测试中,都必须使用 TestDispatcher 代替 Main 调度程序,例如视图模型中的 getMarsPhotos() 函数。

要在所有情况下都将 Main 调度程序替换为 TestDispatcher,请使用 Dispatchers.setMain() 函数。您可以使用 Dispatchers.resetMain() 函数将线程调度程序重置回 Main 调度程序。为了避免在每个测试中都重复替换 Main 调度程序的代码,您可以将其提取到 JUnit 测试规则中。TestRule 提供了一种控制运行测试的环境的方法。TestRule 可以添加额外的检查,可以为测试执行必要的设置或清理,或者可以观察测试执行以将其报告到其他地方。它们可以轻松地在测试类之间共享。

创建一个专用类来编写用于替换 Main 调度程序的 TestRule。要实现自定义 TestRule,请完成以下步骤

  1. 在测试目录中创建一个名为 rules 的新包。
  2. 在 rules 目录中,创建一个名为 TestDispatcherRule 的新类。
  3. 使用 TestWatcher 扩展 TestDispatcherRuleTestWatcher 类允许您在测试的不同执行阶段采取操作。
class TestDispatcherRule(): TestWatcher(){

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

此参数允许使用不同的调度器,例如 StandardTestDispatcher。 此构造函数参数需要具有一个默认值,该值设置为 UnconfinedTestDispatcher 对象的实例。 UnconfinedTestDispatcher 类继承自 TestDispatcher 类,它指定任务不能以任何特定顺序执行。 这种执行模式对于简单的测试很合适,因为协程会自动处理。 与 UnconfinedTestDispatcher 不同,StandardTestDispatcher 类允许完全控制协程执行。 这种方式更适合需要手动方法的复杂测试,但在本代码实验室中的测试中并不需要。

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. 获取解决方案代码

要下载完成代码实验室的代码,您可以使用以下命令

$ 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 中打开它。

如果您想查看此代码实验室的解决方案代码,请在 GitHub 上查看。

12. 结论

恭喜您完成本代码实验室,并将 Mars Photos 应用程序重构为实现存储库模式和依赖注入!

应用程序的代码现在遵循数据层的 Android 最佳实践,这意味着它更灵活、更健壮且易于扩展。

这些更改还有助于使应用程序更容易测试。 这项优势非常重要,因为代码可以继续发展,同时确保它仍然按预期工作。

不要忘记在社交媒体上分享您的作品,使用 #AndroidBasics

13. 了解更多

Android 开发者文档

其他