构建数据层

1. 准备工作

在此 Codelab 中,您将学习数据层的相关知识,并了解如何将数据层嵌入您的整体应用架构

数据层是位于网域层和界面层之下的底层。

图 1. 示意图:数据层是网域层和界面层所依赖的层。

您将构建任务管理应用的数据层。为此,您需要为本地数据库和网络服务创建数据源,还需要创建一个用于公开、更新和同步数据的存储库。

前提条件

学习内容

在此 Codelab 中,您将学习如何:

  • 创建存储库、数据源和数据模型,实现高效且可扩缩的数据管理。
  • 向其他架构层公开数据。
  • 处理异步数据更新以及复杂任务或长时间运行的任务。
  • 在多个数据源之间同步数据。
  • 创建用于验证存储库和数据源行为的测试。

您将构建的内容

您将构建一个任务管理应用,您可以在其中添加任务并将任务标记为“已完成”。

您无需从头开始编写应用,而是将以一个已经拥有界面层的应用为基础进行开发。该应用的界面层包含使用 ViewModel 实现的界面和界面级状态容器。

在学习此 Codelab 的过程中,您将添加数据层,然后将其连接到现有界面层,使应用能够完全正常运行。

任务列表界面。

任务详情界面。

图 2. 屏幕截图:任务列表界面。

图 3. 屏幕截图:任务详情界面。

2. 进行设置

  1. 下载代码:

https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip

  1. 或者,您也可以克隆该代码的 GitHub 代码库:
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. 打开 Android Studio 并加载 architecture-samples 项目。

文件夹结构

  • Android 视图中打开 Project Explorer。

java/com.example.android.architecture.blueprints.todoapp 文件夹下有若干文件夹。

Android 视图中的 Android Studio Project Explorer 窗口。

图 4. 屏幕截图:Android 视图中的 Android Studio Project Explorer 窗口。

  • <root> 包含应用级类,例如用于导航、主 activity 和应用类的类。
  • addedittask 包含使用户可以添加和修改任务的界面功能。
  • data 包含数据层。您的工作主要在此文件夹中进行。
  • di 包含用于依赖项注入的 Hilt 模块。
  • tasks 包含使用户可以查看和更新任务列表的界面功能。
  • util 包含实用程序类。

此外,还有两个测试文件夹,相应文件夹名称的结尾会以带括号的文字加以标示。

  • androidTest 遵循与 <root> 相同的结构,但包含“插桩测试”。
  • test 遵循与 <root> 相同的结构,但包含“本地测试”。

运行项目

  • 点击顶部工具栏中的绿色播放图标。

Android Studio 的运行配置、目标设备和运行按钮。

图 5. 屏幕截图:Android Studio 的运行配置、目标设备和运行按钮。

您应该会看到“任务列表”界面,该界面上有一个不会消失的加载旋转图标。

应用处于起始状态,且带有不会消失的加载旋转图标。

图 6. 屏幕截图:应用处于起始状态,且带有不会消失的加载旋转图标。

完成此 Codelab 后,此界面将显示一个任务列表。

在此 Codelab 中,您可以通过查看 data-codelab-final 分支来查看最终代码。

git checkout data-codelab-final

在此之前,别忘了保存您的更改!

3. 了解数据层

在此 Codelab 中,您将为应用构建数据层。

顾名思义,数据层是一个用于管理应用数据的架构层。它还包含业务逻辑,即现实世界中决定应用数据如何创建、存储和更改的业务规则。通过以这种方式分离关注点,数据层可实现重复使用,因而能够呈现在多个界面上、在应用的不同部分之间共享信息,以及在界面以外复制业务逻辑以进行单元测试。

数据层的关键组件分为以下类型:数据模型、数据源和存储库。

数据层的组件类型,包括数据模型、数据源和存储库之间的依赖关系。

图 7. 示意图:数据层组件类型,包括数据模型、数据源和存储库之间的依赖关系。

数据模型

应用数据通常表示为数据模型。数据模型是数据在内存中的表示形式。

由于此应用属于任务管理应用,因此您需要一个适用于“任务”的数据模型。以下是 Task 类的代码:

data class Task(
    val id: String
    val title: String = "",
    val description: String = "",
    val isCompleted: Boolean = false,
) { ... }

此模型的要点在于它“不可变”。其他层无法更改任务属性;如果它们需要更改任务,则必须使用数据层。

内部和外部数据模型

Task 是“外部”数据模型的示例。它由数据层对外公开,可供其他层访问。稍后,您将定义仅在数据层内部使用的“内部”数据模型。

建议在定义数据模型时,用一个数据模型表达一种业务模式。此应用中有三个数据模型。

模型名称

对于数据层,是外部模型还是内部模型?

表示的任务

关联的数据源

Task

外部

可在应用内的任何位置使用的任务、仅存储在内存中的任务,或在保存应用状态时执行的任务

不适用

LocalTask

内部

存储在本地数据库中的任务

TaskDao

NetworkTask

内部

已从网络服务器检索到的任务

NetworkTaskDataSource

数据源

数据源是一个负责对“单个来源”(例如数据库或网络服务)执行数据读写操作的类。

此应用中有两个数据源:

  • TaskDao 是对数据库执行读写操作的本地数据源。
  • NetworkTaskDataSource 是对网络服务器执行读写操作的网络数据源。

仓库

每个存储库应用于管理一个数据模型。在此应用中,您将创建一个用于管理 Task 模型的存储库。存储库:

  • 公开 Task 模型的列表。
  • 提供用于创建和更新 Task 模型的方法。
  • 执行业务逻辑,例如为每个任务创建一个唯一 ID。
  • 将来自数据源的内部数据模型合并到一起或映射到 Task 模型。
  • 同步多个数据源。

开始编写代码吧!

  • 切换到 Android 视图,然后展开 com.example.android.architecture.blueprints.todoapp.data 软件包:

显示文件夹和文件的 Project Explorer 窗口。

图 8. 显示文件夹和文件的 Project Explorer 窗口。

Task 类已事先创建,以确保应用的其余部分可以编译。如此一来,您就可以通过向已提供的空 .kt 文件中添加实现,来从零创建大多数数据层类。

4. 在本地存储数据

在此步骤中,您将为用于在设备本地存储任务的 Room 数据库创建数据源和数据模型

任务存储库、模型、数据源和数据库之间的关系。

图 9. 示意图:任务存储库、模型、数据源和数据库之间的关系。

创建数据模型

为了将数据存储到 Room 数据库中,您需要创建一个数据库实体。

  • 打开 data/source/local 内的 LocalTask.kt 文件,然后向其中添加以下代码:
@Entity(
    tableName = "task"
)
data class LocalTask(
    @PrimaryKey val id: String,
    var title: String,
    var description: String,
    var isCompleted: Boolean,
)

LocalTask 类表示 Room 数据库中名为 task表中存储的数据。它与 Room 紧密耦合,不应该用于其他数据源(例如 DataStore)。

类名称中的 Local 前缀用于表示此数据存储在本地。该前缀也用于区分此类与 Task 数据模型,而后者会对应用中的其他层公开。换而言之,LocalTask 是数据层的“内部”层,Task 是数据层的“外部”层。

创建数据源

现在,您有了一个数据模型,并创建了一个数据源,用于对 LocalTask 模型执行创建、读取、更新和删除 (CRUD) 操作。由于您使用的是 Room,因此可以使用数据访问对象@Dao 注解)作为本地数据源。

  • 在名为 TaskDao.kt 的文件中创建一个新的 Kotlin 接口。
@Dao
interface TaskDao {

    @Query("SELECT * FROM task")
    fun observeAll(): Flow<List<LocalTask>>

    @Upsert
    suspend fun upsert(task: LocalTask)

    @Upsert
    suspend fun upsertAll(tasks: List<LocalTask>)

    @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
    suspend fun updateCompleted(taskId: String, completed: Boolean)

    @Query("DELETE FROM task")
    suspend fun deleteAll()
}

用于“读取数据”的方法带有 observe 前缀。这些方法是非挂起函数,会返回一个 Flow。每当底层数据发生变化时,都会有一个新项发送到数据流。Room 库(以及许多其他数据存储库)的这项实用功能意味着,您可以监听数据更改,而不是通过轮询数据库来获取新数据。

用于“写入数据”的方法为挂起函数,因为它们会执行 I/O 操作。

更新数据库架构

接下来,您需要更新数据库,使其存储 LocalTask 模型。

  1. 打开 ToDoDatabase.kt 并将 BlankEntity 更改为 LocalTask
  2. 移除 BlankEntity 以及任何多余的 import 语句。
  3. 添加一个方法,以返回名为 taskDao 的 DAO。

更新后的类应如下所示:

@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {

    abstract fun taskDao(): TaskDao
}

更新 Hilt 配置

此项目使用 Hilt 进行依赖项注入。Hilt 需要知道如何创建 TaskDao,这样才能将它注入到使用它的类中。

  • 打开 di/DataModules.kt,并将以下方法添加到 DatabaseModule
    @Provides
    fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()

至此,您已经完成了对本地数据库执行读写任务所需的全部操作。

5. 测试本地数据源

在上一步中,您编写了很多代码,但如何确定这些代码能否正常运行?在 TaskDao 中使用这些 SQL 查询时出现错误,是很常见的事。所以,您需要创建测试来验证 TaskDao 是否按预期运行。

测试不是应用的组成部分,因此应放在其他文件夹中。我们有两个测试文件夹,相应文件包名称的结尾会以带括号的文字加以标示:

Project Explorer 中的 test 文件夹和 androidTest 文件夹。

图 10. 屏幕截图:Project Explorer 中的 test 文件夹和 androidTest 文件夹。

  • androidTest 包含在 Android 模拟器或 Android 设备上运行的测试。这些测试称为插桩测试
  • test 包含在主机上运行的测试,也称为本地测试

TaskDao 需要使用 Room 数据库(只能在 Android 设备上创建)。因此,如需对其进行测试,您需要创建插桩测试。

创建测试类

  • 展开 androidTest 文件夹并打开 TaskDaoTest.kt。在其中创建一个名为 TaskDaoTest 的空类。
class TaskDaoTest {

}

添加测试数据库

  • 添加 ToDoDatabase,并在每次测试之前对其进行初始化。
    private lateinit var database: ToDoDatabase

    @Before
    fun initDb() {
        database = Room.inMemoryDatabaseBuilder(
            getApplicationContext(),
            ToDoDatabase::class.java
        ).allowMainThreadQueries().build()
    }

以上代码将在每次测试之前创建一个内存中数据库。内存中数据库比基于磁盘的数据库快得多。这非常适合自动化测试,在这种测试中,数据的保留期限不需超过测试时间。

添加测试

添加一项测试,用于验证是否可以插入 LocalTask,以及是否可以使用 TaskDao 读取该 LocalTask

此 Codelab 中的测试都遵循 Given-When-Then 结构:

Given

空数据库

When

已插入任务,并且您开始观察任务流

Then

任务流中的第一项与插入的任务匹配

  1. 首先创建一个失败测试。这可以验证测试是否实际运行,以及测试的对象及其依赖项是否正确。
@Test
fun insertTaskAndGetTasks() = runTest {

    val task = LocalTask(
        title = "title",
        description = "description",
        id = "id",
        isCompleted = false,
    )
    database.taskDao().upsert(task)

    val tasks = database.taskDao().observeAll().first()

    assertEquals(0, tasks.size)
}
  1. 点击边线中测试旁边的 Play 按钮以运行测试。

代码编辑器边线中与测试对应的 Play 按钮。

图 11. 屏幕截图:代码编辑器边线中与测试对应的 Play 按钮。

测试结果窗口中应该会显示测试失败并显示以下消息:expected:<0> but was:<1>。这是预期结果,因为数据库中的任务数量是 1,而不是 0。

失败的测试。

图 12. 屏幕截图:失败测试。

  1. 移除现有的 assertEquals 语句。
  2. 添加代码,测试数据源是否提供且仅提供了一个任务,而且该任务与插入的任务相同。

assertEquals 的参数顺序应始终为“预期值”在前、“实际值”在后**。**

assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
  1. 再次运行测试。测试结果窗口中应该会显示测试成功通过。

测试成功通过。

图 13. 屏幕截图:测试成功通过。

6. 创建网络数据源

将任务保存在设备本地是一个好办法,但如果您也希望在网络服务中保存和加载这些任务,该怎么办?或许,您的 Android 应用只是用户能用于向待办事项列表添加任务的途径之一。网站或桌面应用也是管理任务的其他途径。或者,您可能希望提供在线数据备份功能,以便用户能在更换设备后恢复应用数据。

在这些情况下,您通常应该有一个基于网络的服务,所有客户端(包括您的 Android 应用)都可以使用该服务来加载和保存数据。

在下一步中,您将创建一个数据源,用于与网络服务通信。在此 Codelab 中,这是一项模拟服务,不会连接到实时网络服务,但可以让您了解如何在真实的应用中实现这个功能。

关于网络服务

在本示例中,网络 API 非常简单。它仅执行两项操作:

  • 保存所有任务,覆盖之前写入的所有数据。
  • 加载所有任务,并提供网络服务中当前保存的所有任务的列表。

为网络数据建模

从网络 API 获取数据时,外来数据与本地数据具有不同的表示方法是很常见的。任务的网络表示方法可能包含额外的字段,或者可能会使用其他类型或字段名称来表示既有值。

考虑到这些差异,请创建网络特定的数据模型。

  • 打开在 data/source/network 中找到的文件 NetworkTask.kt,然后添加以下代码来表示各个字段:
data class NetworkTask(
    val id: String,
    val title: String,
    val shortDescription: String,
    val priority: Int? = null,
    val status: TaskStatus = TaskStatus.ACTIVE
) {
    enum class TaskStatus {
        ACTIVE,
        COMPLETE
    }
}

LocalTaskNetworkTask 之间的区别如下:

  • 任务说明命名为 shortDescription,而非 description
  • isCompleted 字段表示为 status 枚举,它有两个可能的值:ACTIVECOMPLETE
  • 后者包含一个额外的 priority 字段,是一个整数。

创建网络数据源

  • 打开 TaskNetworkDataSource.kt,然后创建一个名为 TaskNetworkDataSource 且包含以下内容的类:
class TaskNetworkDataSource @Inject constructor() {

    // A mutex is used to ensure that reads and writes are thread-safe.
    private val accessMutex = Mutex()
    private var tasks = listOf(
        NetworkTask(
            id = "PISA",
            title = "Build tower in Pisa",
            shortDescription = "Ground looks good, no foundation work required."
        ),
        NetworkTask(
            id = "TACOMA",
            title = "Finish bridge in Tacoma",
            shortDescription = "Found awesome girders at half the cost!"
        )
    )

    suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        return tasks
    }

    suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        tasks = newTasks
    }
}

private const val SERVICE_LATENCY_IN_MILLIS = 2000L

此对象会模拟与服务器的交互,包括针对每次 loadTaskssaveTasks 调用模拟 2 秒的延迟。这可以表示网络或服务器响应延迟。

此对象还包含一些测试数据,您稍后可以使用这些数据来验证能否从网络成功加载任务。

如果您的真实服务器 API 使用 HTTP,不妨考虑使用 KtorRetrofit 等库来构建网络数据源。

7. 创建任务存储库

这一步将把两个模型整合在一起。

DefaultTaskRepository 的依赖项。

图 14. 示意图:DefaultTaskRepository 的依赖项。

现在,我们有两个数据源:一个用于本地数据 (TaskDao),另一个用于网络数据 (TaskNetworkDataSource)。每个数据源都允许读写,并具有各自的任务表示方法(分别为 LocalTaskNetworkTask)。

接下来,您需要创建一个存储库来使用这些数据源并提供 API,以便其他架构层能够访问这些任务数据。

公开数据

  1. 打开 data 文件包中的 DefaultTaskRepository.kt,然后创建一个名为 DefaultTaskRepository 的类,该类将 TaskDaoTaskNetworkDataSource 作为依赖项。
class DefaultTaskRepository @Inject constructor(
    private val localDataSource: TaskDao,
    private val networkDataSource: TaskNetworkDataSource,
) {

}

应使用 Flow 来公开数据。这样,调用方就可以随时了解数据的变化。

  1. 添加一个名为 observeAll 的方法,该方法会使用 Flow 来返回 Task 模型的数据流。
fun observeAll() : Flow<List<Task>> {
    // TODO add code to retrieve Tasks
}

存储库应公开来自单一可信来源的数据。也就是说,数据只能来自一个数据源。该数据源可以是内存中缓存或远程服务器,也可以是本示例中使用的本地数据库。

您可以使用 TaskDao.observeAll 来访问本地数据库中的任务,从而方便地返回 Flow。但需要注意的是,这是 LocalTask 模型的 Flow,而 LocalTask 是不应向其他架构层公开的内部模型。

所以,您需要将 LocalTask 转换为 Task。后者是一个外部模型,构成了数据层 API 的一部分。

将内部模型映射到外部模型

为了执行转换,您需要将 LocalTask 中的字段映射到 Task 中的字段。

  1. 为此,请在 LocalTask 中创建扩展函数
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)

// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }

现在,每当您需要将 LocalTask 转换为 Task 时,只需调用 toExternal 即可。

  1. observeAll 中使用您新创建的 toExternal 函数:
fun observeAll(): Flow<List<Task>> {
    return localDataSource.observeAll().map { tasks ->
        tasks.toExternal()
    }
}

每当本地数据库中的任务数据发生更改时,都会有一个新的 LocalTask 模型列表发送到 Flow。然后,每个 LocalTask 都会映射到一个 Task

太好了!现在,其他层可以使用 observeAll 从您的本地数据库获取所有 Task 模型,并在这些 Task 模型发生变化时收到通知。

更新数据

如果无法创建和更新任务,TODO 应用的价值就会大打折扣。因此,您现在需要添加方法来实现这些功能。

用于创建、更新或删除数据的方法是一次性操作,应使用 suspend 函数来实现。

  1. 添加一个名为 create 的方法,该方法接受 titledescription 作为参数,并返回新创建的任务的 ID。
suspend fun create(title: String, description: String): String {
}

请注意,为了禁止其他层创建 Task,数据层 API 仅提供了可以接受单个参数(而非 Task)的 create 方法。此方法封装了以下信息:

  • 创建唯一任务 ID 所遵循的业务逻辑。
  • 任务最初创建后的存储位置。
  1. 添加用于创建任务 ID 的方法
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. 使用新添加的 createTaskId 方法创建一个任务 ID
suspend fun create(title: String, description: String): String {
    val taskId = createTaskId()
}

避免阻塞主线程

但是先别急!如果创建任务 ID 的计算成本很高,该怎么办?或许这会涉及使用加密技术为 ID 创建哈希键,因而需要几秒钟的时间。如果在主线程上调用,可能会导致界面卡顿。

数据层有责任“确保长时间运行的任务或复杂任务不会阻塞主线程”。

要解决此问题,请指定一个协程调度程序,专门用于执行这些指令。

  1. 首先,将 CoroutineDispatcher 作为依赖项添加到 DefaultTaskRepository 中。用已创建的 @DefaultDispatcher 限定符(在 di/CoroutinesModule.kt 中定义)告知 Hilt 使用 Dispatchers.Default 注入此依赖项。之所以指定 Default 调度程序,是因为它针对 CPU 密集型工作进行了优化。如需详细了解协程调度程序,请点击此处
class DefaultTaskRepository @Inject constructor(
   private val localDataSource: TaskDao,
   private val networkDataSource: TaskNetworkDataSource,
   @DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
  1. 现在,在 withContext 代码块内调用 UUID.randomUUID().toString()
val taskId = withContext(dispatcher) {
    createTaskId()
}

详细了解数据层中的线程处理

创建并存储任务

  1. 现在,您已经有了任务 ID,可以将其与提供的参数结合起来,创建新的 Task
suspend fun create(title: String, description: String): String {
    val taskId = withContext(dispatcher) {
        createTaskId()
    }
    val task = Task(
        title = title,
        description = description,
        id = taskId,
    )
}

在将任务插入本地数据源之前,您需要将其映射到 LocalTask

  1. 将以下扩展函数添加到 LocalTask 的末尾。这是您之前创建的 LocalTask.toExternal 的反向映射函数。
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. create 中使用以下代码将任务插入本地数据源,然后返回 taskId
suspend fun create(title: String, description: String): Task {
    ...
    localDataSource.upsert(task.toLocal())
    return taskId
}

完成任务

  • 再创建一个 complete 方法,用于将 Task 标记为“已完成”。
suspend fun complete(taskId: String) {
    localDataSource.updateCompleted(taskId, true)
}

现在,您有了用于创建和完成任务的实用方法。

同步数据

在此应用中,网络数据源用作在线备份,每当本地写入数据时,都会相应更新。每次用户请求刷新时,应用都会从网络加载数据。

下图总结了每种操作类型的行为。

操作类型

存储库方法

步骤

数据移动

加载

observeAll

从本地数据库加载数据

从本地数据源到任务存储库的数据流。图 15. 示意图:从本地数据源到任务存储库的数据流。

保存

createcomplete

1. 将数据写入本地 database2。将所有数据复制到网络,覆盖所有内容

从任务存储库到本地数据源,再到网络数据源的数据流。图 16. 示意图:从任务存储库到本地数据源,再到网络数据源的数据流。

刷新

refresh

1. 从 network2 加载数据。将数据复制到本地数据库,覆盖所有内容

从网络数据源到本地数据源,再到任务存储库的数据流。图 17. 示意图:从网络数据源到本地数据源,再到任务存储库的数据流。

保存并刷新网络数据

您的存储库已经从本地数据源加载了任务。为了完成同步算法,您需要创建从网络数据源保存和刷新数据的方法。

  1. 首先,在 NetworkTask.kt 中创建 LocalTaskNetworkTask 之间的正向和逆向映射函数。将函数放置到 LocalTask.kt 中也同样有效。
fun NetworkTask.toLocal() = LocalTask(
    id = id,
    title = title,
    description = shortDescription,
    isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)

fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)

fun LocalTask.toNetwork() = NetworkTask(
    id = id,
    title = title,
    shortDescription = description,
    status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)

fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)

这里可以看到为每种数据源使用独立模型的优势:将一种数据类型映射到另一种数据类型时,可以封装为独立的函数。

  1. DefaultTaskRepository 末尾添加 refresh 方法。
suspend fun refresh() {
    val networkTasks = networkDataSource.loadTasks()
    localDataSource.deleteAll()
    val localTasks = withContext(dispatcher) {
        networkTasks.toLocal()
    }
    localDataSource.upsertAll(networkTasks.toLocal())
}

这会将所有“本地”任务替换为从“网络”获取的任务。withContext 用于批量执行 toLocal 操作,因为任务数量未知,而且每个映射操作的计算开销都很高。

  1. DefaultTaskRepository 末尾添加 saveTasksToNetwork 方法。
private suspend fun saveTasksToNetwork() {
    val localTasks = localDataSource.observeAll().first()
    val networkTasks = withContext(dispatcher) {
        localTasks.toNetwork()
    }
    networkDataSource.saveTasks(networkTasks)
}

这会将所有“网络”任务替换为来自“本地”数据源的任务。

  1. 现在,更新现有方法,以便更新 createcomplete 任务,在本地数据发生更改时将数据保存到网络。
    suspend fun create(title: String, description: String): String {
        ...
        saveTasksToNetwork()
        return taskId
    }

     suspend fun complete(taskId: String) {
        localDataSource.updateCompleted(taskId, true)
        saveTasksToNetwork()
    }

避免调用方等待

如果您运行此代码,会注意到 saveTasksToNetwork 发生阻塞。这意味着,createcomplete 的调用方不得不等到数据保存到网络后,才能确定操作已完成。在模拟网络数据源中,这一延迟只有 2 秒。但在真实应用中,可能会长得多,甚至在没有网络连接时根本无法运行。

这会造成不必要的限制,而且可能导致糟糕的用户体验,因为没人愿意为了创建任务而等待,特别是在工作繁忙的情况下!

更好的解决方案是,使用不同的协程作用域将数据保存到网络中。这将允许在后台完成操作,而无需使调用方等待结果。

  1. 将协程作用域作为参数添加到 DefaultTaskRepository
class DefaultTaskRepository @Inject constructor(
    // ...other parameters...
    @ApplicationScope private val scope: CoroutineScope,
)

Hilt 限定符 @ApplicationScope(在 di/CoroutinesModule.kt 中定义)用于注入遵循应用生命周期的作用域。

  1. 使用 scope.launch 将代码封装在 saveTasksToNetwork 中。
    private fun saveTasksToNetwork() {
        scope.launch {
            val localTasks = localDataSource.observeAll().first()
            val networkTasks = withContext(dispatcher) {
                localTasks.toNetwork()
            }
            networkDataSource.saveTasks(networkTasks)
        }
    }

现在,saveTasksToNetwork 会立即返回结果,而将任务保存到网络的操作会在后台执行。

8. 测试任务存储库

哇,数据层中增添了很多功能。接下来,您可以通过为 DefaultTaskRepository 创建单元测试来验证这些功能是否正常运行。

您需要使用本地和网络数据源的测试依赖项对要测试的对象 (DefaultTaskRepository) 进行实例化。首先,您需要创建这些依赖项。

  1. 在 Project Explorer 窗口中,展开 (test) 文件夹,然后展开 source.local 文件夹并打开 FakeTaskDao.kt.

项目文件夹结构中的 FakeTaskDao.kt 文件。

图 18. 屏幕截图:项目文件夹结构中的 FakeTaskDao.kt

  1. 添加以下内容:
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {

    private val _tasks = initialTasks.toMutableList()
    private val tasksStream = MutableStateFlow(_tasks.toList())

    override fun observeAll(): Flow<List<LocalTask>> = tasksStream

    override suspend fun upsert(task: LocalTask) {
        _tasks.removeIf { it.id == task.id }
        _tasks.add(task)
        tasksStream.emit(_tasks)
    }

    override suspend fun upsertAll(tasks: List<LocalTask>) {
        val newTaskIds = tasks.map { it.id }
        _tasks.removeIf { newTaskIds.contains(it.id) }
        _tasks.addAll(tasks)
    }

    override suspend fun updateCompleted(taskId: String, completed: Boolean) {
        _tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
        tasksStream.emit(_tasks)
    }

    override suspend fun deleteAll() {
        _tasks.clear()
        tasksStream.emit(_tasks)
    }
}

在真实应用中,您还需要创建一个虚构依赖项来替换 TaskNetworkDataSource(让虚构对象和真实对象实现一个通用接口)。但在此 Codelab 中,您可以直接使用该依赖项。

  1. DefaultTaskRepositoryTest 内,添加以下内容。

一条规则,用于设置在所有测试中使用的主调度程序。

部分测试数据。

本地数据源和网络数据源的测试依赖项。

要测试的对象:DefaultTaskRepository

class DefaultTaskRepositoryTest {

    private var testDispatcher = UnconfinedTestDispatcher()
    private var testScope = TestScope(testDispatcher)

    private val localTasks = listOf(
        LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
        LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
    )

    private val localDataSource = FakeTaskDao(localTasks)
    private val networkDataSource = TaskNetworkDataSource()
    private val taskRepository = DefaultTaskRepository(
        localDataSource = localDataSource,
        networkDataSource = networkDataSource,
        dispatcher = testDispatcher,
        scope = testScope
    )
}

太好了!现在,您可以开始编写单元测试了。您应测试三个主要方面:读取、写入和数据同步。

测试已公开的数据

您可以通过以下方式测试存储库是否正确公开数据。此测试以 Given-When-Then 结构给定。例如:

Given

本地数据源有一些现有任务

When

使用 observeAll 从存储库获取任务流

Then

任务流中的第一项与本地数据源中任务的外部表示方式完全匹配。

  • 创建一个名为 observeAll_exposesLocalData 且包含以下内容的测试:
@Test
fun observeAll_exposesLocalData() = runTest {
    val tasks = taskRepository.observeAll().first()
    assertEquals(localTasks.toExternal(), tasks)
}

使用 first 函数从任务流中获取第一项。

测试数据更新

接下来,编写一项测试来验证任务是否成功创建并保存到网络数据源。

Given

空数据库

When

通过调用 create 创建一项任务

Then

在本地数据源和网络数据源中均创建该任务

  1. 创建一个名为 onTaskCreation_localAndNetworkAreUpdated 的测试。
@Test
    fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
        val newTaskId = taskRepository.create(
            localTasks[0].title,
            localTasks[0].description
        )

        val localTasks = localDataSource.observeAll().first()
        assertEquals(true, localTasks.map { it.id }.contains(newTaskId))

        val networkTasks = networkDataSource.loadTasks()
        assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
    }

再接下来,验证当任务完成后是否正确写入本地数据源并保存到网络数据源。

Given

本地数据源包含一项任务

When

通过调用 complete 完成该任务

Then

本地数据和网络数据也相应更新

  1. 创建一个名为 onTaskCompletion_localAndNetworkAreUpdated 的测试。
    @Test
    fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
        taskRepository.complete("1")

        val localTasks = localDataSource.observeAll().first()
        val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
        assertEquals(true, isLocalTaskComplete)

        val networkTasks = networkDataSource.loadTasks()
        val isNetworkTaskComplete =
            networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
        assertEquals(true, isNetworkTaskComplete)
    }

测试数据刷新

最后,测试刷新操作是否成功。

Given

网络数据源包含数据

When

调用 refresh

Then

本地数据与网络数据完全相同

  • 创建一个名为 onRefresh_localIsEqualToNetwork 的测试
@Test
    fun onRefresh_localIsEqualToNetwork() = runTest {
        val networkTasks = listOf(
            NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
            NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
        )
        networkDataSource.saveTasks(networkTasks)

        taskRepository.refresh()

        assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
    }

大功告成!运行这些测试,它们应该全部通过。

9. 更新界面层

现在,您已确认数据层能够正常工作,是时候将它连接到界面层了。

更新任务列表界面的视图模型

TasksViewModel 开头。这是用于显示应用中第一个界面(即当前所有活动任务的列表)的视图模型。

  1. 打开该类,将 DefaultTaskRepository 作为构造函数参数添加到其中。
@HiltViewModel
class TasksViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
  1. 使用存储库初始化 tasksStream 变量。
private val tasksStream = taskRepository.observeAll()

现在,您的视图模型将有权访问存储库提供的所有任务,而且每当数据发生更改时,都会收到一份新的任务列表。这只需要短短一行代码!

  1. 剩下的任务就是将用户操作关联到存储库中相应的方法。找到 complete 方法并将其更新为:
fun complete(task: Task, completed: Boolean) {
    viewModelScope.launch {
        if (completed) {
            taskRepository.complete(task.id)
            showSnackbarMessage(R.string.task_marked_complete)
        } else {
            ...
       }
    }
}
  1. refresh 进行同样的更新。
    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            taskRepository.refresh()
            _isLoading.value = false
        }
    }

更新添加任务界面的视图模型

  1. 打开 AddEditTaskViewModel,将 DefaultTaskRepository 作为构造函数参数添加到其中,具体操作与上一步相同。
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. create 方法更新为以下内容:
    private fun createNewTask() = viewModelScope.launch {
        taskRepository.create(uiState.value.title, uiState.value.description)
        _uiState.update {
            it.copy(isTaskSaved = true)
        }
    }

运行应用

  1. 现在到了您期待已久的时刻:是时候运行应用了。您应该会看到一个界面,上面显示您没有任何任务!

没有任务时,应用的任务界面。

图 19. 屏幕截图:没有任务时,应用的任务界面。

  1. 点按右上角的三个点,然后按 Refresh

应用的任务界面,其中显示了操作菜单。

图 20. 屏幕截图:应用的任务界面,其中显示了操作菜单。

您应该会看到一个加载旋转图标持续显示 2 秒,然后您之前添加的测试任务就会出现。

应用的任务界面,其中显示了两个任务。

图 21. 屏幕截图:应用的任务界面,其中显示了两个任务。

  1. 现在,点按右下角的加号以添加新任务。填写标题和说明字段。

应用的添加任务界面。

图 22. 屏幕截图:应用的添加任务界面。

  1. 点按右下角的对勾按钮以保存任务。

添加任务后,应用的任务界面。

图 23. 屏幕截图:添加任务后,应用的任务界面。

  1. 选中任务旁边的复选框,即可将其标记为“已完成”。

应用的任务界面,其中显示了已完成的任务。

图 24. 屏幕截图:应用的任务界面,其中显示了已完成的任务。

10. 恭喜!

您已成功为应用创建数据层。

数据层是应用架构的关键组成部分,是其他层得以构建的基础。因此,如果创建正确,您的应用就能根据用户和企业的实际需求实现扩展。

要点回顾

  • 数据层在 Android 应用架构中的作用。
  • 如何创建数据源和模型。
  • 存储库的作用,以及它们如何公开数据并提供一次性的数据更新方法。
  • 何时需要改用协程调度程序,以及为什么这样做很重要。
  • 使用多个数据源同步数据。
  • 如何为常见的数据层类创建单元测试和插桩测试。

进阶挑战

如果您想进行其他挑战,请实现以下功能:

  • 重新激活已标记为“已完成”的任务。
  • 点按任务可修改标题和说明。

本课程不提供相关说明,一切由您自行完成!如果您遇到困难,请参考 main 分支上提供的功能齐全的应用。

git checkout main

后续步骤

如需详细了解数据层,请参阅官方文档和离线优先应用指南。您还可以深入了解其他架构层,即界面层网域层

如需查看更复杂的真实示例,请参阅 Now in Android 应用