1. 准备工作
在此 Codelab 中,您将学习数据层的相关知识,并了解如何将数据层嵌入您的整体应用架构。
图 1. 示意图:数据层是网域层和界面层所依赖的层。
您将构建任务管理应用的数据层。为此,您需要为本地数据库和网络服务创建数据源,还需要创建一个用于公开、更新和同步数据的存储库。
前提条件
- 这是一个 Codelab 中级课程,您应该对 Android 应用的构建方式有基本的了解(如需获取入门学习资源,请参阅下文)。
- 有使用 Kotlin(包括 lambda、协程和流程)的经验。如需了解如何为 Android 应用编写 Kotlin 代码,请参阅《使用 Kotlin 进行 Android 开发的基础知识》课程的第 1 单元。
- 对 Hilt(依赖项注入)和 Room(数据库存储)库有基本的了解。
- 有一定的 Jetpack Compose 使用经验。《使用 Compose 进行 Android 开发的基础知识》课程的第 1 至 3 单元是学习 Compose 的绝佳途径。
- 可选:阅读架构概览和数据层指南。
- 可选:完成 Room Codelab。
学习内容
在此 Codelab 中,您将学习如何:
- 创建存储库、数据源和数据模型,实现高效且可扩缩的数据管理。
- 向其他架构层公开数据。
- 处理异步数据更新以及复杂任务或长时间运行的任务。
- 在多个数据源之间同步数据。
- 创建用于验证存储库和数据源行为的测试。
您将构建的内容
您将构建一个任务管理应用,您可以在其中添加任务并将任务标记为“已完成”。
您无需从头开始编写应用,而是将以一个已经拥有界面层的应用为基础进行开发。该应用的界面层包含使用 ViewModel 实现的界面和界面级状态容器。
在学习此 Codelab 的过程中,您将添加数据层,然后将其连接到现有界面层,使应用能够完全正常运行。
图 2. 屏幕截图:任务列表界面。 | 图 3. 屏幕截图:任务详情界面。 |
2. 进行设置
- 下载代码:
https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip
- 或者,您也可以克隆该代码的 GitHub 代码库:
git clone https://github.com/android/architecture-samples.git git checkout data-codelab-start
- 打开 Android Studio 并加载
architecture-samples
项目。
文件夹结构
- 在 Android 视图中打开 Project Explorer。
java/com.example.android.architecture.blueprints.todoapp
文件夹下有若干文件夹。
图 4. 屏幕截图:Android 视图中的 Android Studio Project Explorer 窗口。
<root>
包含应用级类,例如用于导航、主 activity 和应用类的类。addedittask
包含使用户可以添加和修改任务的界面功能。data
包含数据层。您的工作主要在此文件夹中进行。di
包含用于依赖项注入的 Hilt 模块。tasks
包含使用户可以查看和更新任务列表的界面功能。util
包含实用程序类。
此外,还有两个测试文件夹,相应文件夹名称的结尾会以带括号的文字加以标示。
androidTest
遵循与<root>
相同的结构,但包含“插桩测试”。test
遵循与<root>
相同的结构,但包含“本地测试”。
运行项目
- 点击顶部工具栏中的绿色播放图标。
图 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
是“外部”数据模型的示例。它由数据层对外公开,可供其他层访问。稍后,您将定义仅在数据层内部使用的“内部”数据模型。
建议在定义数据模型时,用一个数据模型表达一种业务模式。此应用中有三个数据模型。
模型名称 | 对于数据层,是外部模型还是内部模型? | 表示的任务 | 关联的数据源 |
| 外部 | 可在应用内的任何位置使用的任务、仅存储在内存中的任务,或在保存应用状态时执行的任务 | 不适用 |
| 内部 | 存储在本地数据库中的任务 |
|
| 内部 | 已从网络服务器检索到的任务 |
|
数据源
数据源是一个负责对“单个来源”(例如数据库或网络服务)执行数据读写操作的类。
此应用中有两个数据源:
TaskDao
是对数据库执行读写操作的本地数据源。NetworkTaskDataSource
是对网络服务器执行读写操作的网络数据源。
仓库
每个存储库应用于管理一个数据模型。在此应用中,您将创建一个用于管理 Task
模型的存储库。存储库:
- 公开
Task
模型的列表。 - 提供用于创建和更新
Task
模型的方法。 - 执行业务逻辑,例如为每个任务创建一个唯一 ID。
- 将来自数据源的内部数据模型合并到一起或映射到
Task
模型。 - 同步多个数据源。
开始编写代码吧!
- 切换到 Android 视图,然后展开
com.example.android.architecture.blueprints.todoapp.data
软件包:
图 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
模型。
- 打开
ToDoDatabase.kt
并将BlankEntity
更改为LocalTask
。 - 移除
BlankEntity
以及任何多余的import
语句。 - 添加一个方法,以返回名为
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
是否按预期运行。
测试不是应用的组成部分,因此应放在其他文件夹中。我们有两个测试文件夹,相应文件包名称的结尾会以带括号的文字加以标示:
图 10. 屏幕截图:Project Explorer 中的 test 文件夹和 androidTest 文件夹。
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 | 任务流中的第一项与插入的任务匹配 |
- 首先创建一个失败测试。这可以验证测试是否实际运行,以及测试的对象及其依赖项是否正确。
@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)
}
- 点击边线中测试旁边的 Play 按钮以运行测试。
图 11. 屏幕截图:代码编辑器边线中与测试对应的 Play 按钮。
测试结果窗口中应该会显示测试失败并显示以下消息:expected:<0> but was:<1>
。这是预期结果,因为数据库中的任务数量是 1,而不是 0。
图 12. 屏幕截图:失败测试。
- 移除现有的
assertEquals
语句。 - 添加代码,测试数据源是否提供且仅提供了一个任务,而且该任务与插入的任务相同。
assertEquals
的参数顺序应始终为“预期值”在前、“实际值”在后**。**
assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
- 再次运行测试。测试结果窗口中应该会显示测试成功通过。
图 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
}
}
LocalTask
和 NetworkTask
之间的区别如下:
- 任务说明命名为
shortDescription
,而非description
。 isCompleted
字段表示为status
枚举,它有两个可能的值:ACTIVE
和COMPLETE
。- 后者包含一个额外的
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
此对象会模拟与服务器的交互,包括针对每次 loadTasks
或 saveTasks
调用模拟 2 秒的延迟。这可以表示网络或服务器响应延迟。
此对象还包含一些测试数据,您稍后可以使用这些数据来验证能否从网络成功加载任务。
7. 创建任务存储库
这一步将把两个模型整合在一起。
图 14. 示意图:DefaultTaskRepository
的依赖项。
现在,我们有两个数据源:一个用于本地数据 (TaskDao
),另一个用于网络数据 (TaskNetworkDataSource
)。每个数据源都允许读写,并具有各自的任务表示方法(分别为 LocalTask
和 NetworkTask
)。
接下来,您需要创建一个存储库来使用这些数据源并提供 API,以便其他架构层能够访问这些任务数据。
公开数据
- 打开
data
文件包中的DefaultTaskRepository.kt
,然后创建一个名为DefaultTaskRepository
的类,该类将TaskDao
和TaskNetworkDataSource
作为依赖项。
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
) {
}
应使用 Flow 来公开数据。这样,调用方就可以随时了解数据的变化。
- 添加一个名为
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
中的字段。
- 为此,请在
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
即可。
- 在
observeAll
中使用您新创建的toExternal
函数:
fun observeAll(): Flow<List<Task>> {
return localDataSource.observeAll().map { tasks ->
tasks.toExternal()
}
}
每当本地数据库中的任务数据发生更改时,都会有一个新的 LocalTask
模型列表发送到 Flow。然后,每个 LocalTask
都会映射到一个 Task
。
太好了!现在,其他层可以使用 observeAll
从您的本地数据库获取所有 Task
模型,并在这些 Task
模型发生变化时收到通知。
更新数据
如果无法创建和更新任务,TODO 应用的价值就会大打折扣。因此,您现在需要添加方法来实现这些功能。
用于创建、更新或删除数据的方法是一次性操作,应使用 suspend
函数来实现。
- 添加一个名为
create
的方法,该方法接受title
和description
作为参数,并返回新创建的任务的 ID。
suspend fun create(title: String, description: String): String {
}
请注意,为了禁止其他层创建 Task
,数据层 API 仅提供了可以接受单个参数(而非 Task
)的 create
方法。此方法封装了以下信息:
- 创建唯一任务 ID 所遵循的业务逻辑。
- 任务最初创建后的存储位置。
- 添加用于创建任务 ID 的方法
// This method might be computationally expensive
private fun createTaskId() : String {
return UUID.randomUUID().toString()
}
- 使用新添加的
createTaskId
方法创建一个任务 ID
suspend fun create(title: String, description: String): String {
val taskId = createTaskId()
}
避免阻塞主线程
但是先别急!如果创建任务 ID 的计算成本很高,该怎么办?或许这会涉及使用加密技术为 ID 创建哈希键,因而需要几秒钟的时间。如果在主线程上调用,可能会导致界面卡顿。
数据层有责任“确保长时间运行的任务或复杂任务不会阻塞主线程”。
要解决此问题,请指定一个协程调度程序,专门用于执行这些指令。
- 首先,将
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,
)
- 现在,在
withContext
代码块内调用UUID.randomUUID().toString()
。
val taskId = withContext(dispatcher) {
createTaskId()
}
创建并存储任务
- 现在,您已经有了任务 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
。
- 将以下扩展函数添加到
LocalTask
的末尾。这是您之前创建的LocalTask.toExternal
的反向映射函数。
fun Task.toLocal() = LocalTask(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
- 在
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)
}
现在,您有了用于创建和完成任务的实用方法。
同步数据
在此应用中,网络数据源用作在线备份,每当本地写入数据时,都会相应更新。每次用户请求刷新时,应用都会从网络加载数据。
下图总结了每种操作类型的行为。
操作类型 | 存储库方法 | 步骤 | 数据移动 |
加载 |
| 从本地数据库加载数据 | 图 15. 示意图:从本地数据源到任务存储库的数据流。 |
保存 |
| 1. 将数据写入本地 database2。将所有数据复制到网络,覆盖所有内容 | 图 16. 示意图:从任务存储库到本地数据源,再到网络数据源的数据流。 |
刷新 |
| 1. 从 network2 加载数据。将数据复制到本地数据库,覆盖所有内容 | 图 17. 示意图:从网络数据源到本地数据源,再到任务存储库的数据流。 |
保存并刷新网络数据
您的存储库已经从本地数据源加载了任务。为了完成同步算法,您需要创建从网络数据源保存和刷新数据的方法。
- 首先,在
NetworkTask.kt
中创建LocalTask
与NetworkTask
之间的正向和逆向映射函数。将函数放置到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)
这里可以看到为每种数据源使用独立模型的优势:将一种数据类型映射到另一种数据类型时,可以封装为独立的函数。
- 在
DefaultTaskRepository
末尾添加refresh
方法。
suspend fun refresh() {
val networkTasks = networkDataSource.loadTasks()
localDataSource.deleteAll()
val localTasks = withContext(dispatcher) {
networkTasks.toLocal()
}
localDataSource.upsertAll(networkTasks.toLocal())
}
这会将所有“本地”任务替换为从“网络”获取的任务。withContext
用于批量执行 toLocal
操作,因为任务数量未知,而且每个映射操作的计算开销都很高。
- 在
DefaultTaskRepository
末尾添加saveTasksToNetwork
方法。
private suspend fun saveTasksToNetwork() {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
这会将所有“网络”任务替换为来自“本地”数据源的任务。
- 现在,更新现有方法,以便更新
create
和complete
任务,在本地数据发生更改时将数据保存到网络。
suspend fun create(title: String, description: String): String {
...
saveTasksToNetwork()
return taskId
}
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
saveTasksToNetwork()
}
避免调用方等待
如果您运行此代码,会注意到 saveTasksToNetwork
发生阻塞。这意味着,create
和 complete
的调用方不得不等到数据保存到网络后,才能确定操作已完成。在模拟网络数据源中,这一延迟只有 2 秒。但在真实应用中,可能会长得多,甚至在没有网络连接时根本无法运行。
这会造成不必要的限制,而且可能导致糟糕的用户体验,因为没人愿意为了创建任务而等待,特别是在工作繁忙的情况下!
更好的解决方案是,使用不同的协程作用域将数据保存到网络中。这将允许在后台完成操作,而无需使调用方等待结果。
- 将协程作用域作为参数添加到
DefaultTaskRepository
。
class DefaultTaskRepository @Inject constructor(
// ...other parameters...
@ApplicationScope private val scope: CoroutineScope,
)
Hilt 限定符 @ApplicationScope
(在 di/CoroutinesModule.kt
中定义)用于注入遵循应用生命周期的作用域。
- 使用
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
) 进行实例化。首先,您需要创建这些依赖项。
- 在 Project Explorer 窗口中,展开
(test)
文件夹,然后展开source.local
文件夹并打开FakeTaskDao.kt.
图 18. 屏幕截图:项目文件夹结构中的 FakeTaskDao.kt
。
- 添加以下内容:
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 中,您可以直接使用该依赖项。
- 在
DefaultTaskRepositoryTest
内,添加以下内容。
一条规则,用于设置在所有测试中使用的主调度程序。 |
部分测试数据。 |
本地数据源和网络数据源的测试依赖项。 |
要测试的对象: |
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 | 使用 |
Then | 任务流中的第一项与本地数据源中任务的外部表示方式完全匹配。 |
- 创建一个名为
observeAll_exposesLocalData
且包含以下内容的测试:
@Test
fun observeAll_exposesLocalData() = runTest {
val tasks = taskRepository.observeAll().first()
assertEquals(localTasks.toExternal(), tasks)
}
使用 first
函数从任务流中获取第一项。
测试数据更新
接下来,编写一项测试来验证任务是否成功创建并保存到网络数据源。
Given | 空数据库 |
When | 通过调用 |
Then | 在本地数据源和网络数据源中均创建该任务 |
- 创建一个名为
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 | 通过调用 |
Then | 本地数据和网络数据也相应更新 |
- 创建一个名为
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 | 调用 |
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
开头。这是用于显示应用中第一个界面(即当前所有活动任务的列表)的视图模型。
- 打开该类,将
DefaultTaskRepository
作为构造函数参数添加到其中。
@HiltViewModel
class TasksViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
- 使用存储库初始化
tasksStream
变量。
private val tasksStream = taskRepository.observeAll()
现在,您的视图模型将有权访问存储库提供的所有任务,而且每当数据发生更改时,都会收到一份新的任务列表。这只需要短短一行代码!
- 剩下的任务就是将用户操作关联到存储库中相应的方法。找到
complete
方法并将其更新为:
fun complete(task: Task, completed: Boolean) {
viewModelScope.launch {
if (completed) {
taskRepository.complete(task.id)
showSnackbarMessage(R.string.task_marked_complete)
} else {
...
}
}
}
- 对
refresh
进行同样的更新。
fun refresh() {
_isLoading.value = true
viewModelScope.launch {
taskRepository.refresh()
_isLoading.value = false
}
}
更新添加任务界面的视图模型
- 打开
AddEditTaskViewModel
,将DefaultTaskRepository
作为构造函数参数添加到其中,具体操作与上一步相同。
class AddEditTaskViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
)
- 将
create
方法更新为以下内容:
private fun createNewTask() = viewModelScope.launch {
taskRepository.create(uiState.value.title, uiState.value.description)
_uiState.update {
it.copy(isTaskSaved = true)
}
}
运行应用
- 现在到了您期待已久的时刻:是时候运行应用了。您应该会看到一个界面,上面显示您没有任何任务!。
图 19. 屏幕截图:没有任务时,应用的任务界面。
- 点按右上角的三个点,然后按 Refresh。
图 20. 屏幕截图:应用的任务界面,其中显示了操作菜单。
您应该会看到一个加载旋转图标持续显示 2 秒,然后您之前添加的测试任务就会出现。
图 21. 屏幕截图:应用的任务界面,其中显示了两个任务。
- 现在,点按右下角的加号以添加新任务。填写标题和说明字段。
图 22. 屏幕截图:应用的添加任务界面。
- 点按右下角的对勾按钮以保存任务。
图 23. 屏幕截图:添加任务后,应用的任务界面。
- 选中任务旁边的复选框,即可将其标记为“已完成”。
图 24. 屏幕截图:应用的任务界面,其中显示了已完成的任务。
10. 恭喜!
您已成功为应用创建数据层。
数据层是应用架构的关键组成部分,是其他层得以构建的基础。因此,如果创建正确,您的应用就能根据用户和企业的实际需求实现扩展。
要点回顾
- 数据层在 Android 应用架构中的作用。
- 如何创建数据源和模型。
- 存储库的作用,以及它们如何公开数据并提供一次性的数据更新方法。
- 何时需要改用协程调度程序,以及为什么这样做很重要。
- 使用多个数据源同步数据。
- 如何为常见的数据层类创建单元测试和插桩测试。
进阶挑战
如果您想进行其他挑战,请实现以下功能:
- 重新激活已标记为“已完成”的任务。
- 点按任务可修改标题和说明。
本课程不提供相关说明,一切由您自行完成!如果您遇到困难,请参考 main
分支上提供的功能齐全的应用。
git checkout main
后续步骤
如需详细了解数据层,请参阅官方文档和离线优先应用指南。您还可以深入了解其他架构层,即界面层和网域层。
如需查看更复杂的真实示例,请参阅 Now in Android 应用。