构建数据层  

您所在的位置:网站首页 service层和dao层区别 构建数据层  

构建数据层  

2023-05-11 14:52| 来源: 网络整理| 查看: 265

1. 准备工作

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

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

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

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

前提条件 这是一个 Codelab 中级课程,您应该对 Android 应用的构建方式有基本的了解(如需获取入门学习资源,请参阅下文)。 有使用 Kotlin(包括 lambda、协程和 Flow)的经验。如需了解如何为 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 文件夹下有若干文件夹。

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

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

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

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

androidTest 遵循与 相同的结构,但包含“插桩测试”。 test 遵循与 相同的结构,但包含“本地测试”。 运行项目 点击顶部工具栏中的绿色播放图标。

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, )

为简洁起见,此 Codelab 中提供的代码省略了 import 语句。您可以使用 Android Studio 的“快捷帮助”来添加这些语句(按 ALT + Enter 组合键,然后将光标悬停在带有红色下划线的元素上)。

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 @Upsert suspend fun upsert(task: LocalTask) @Upsert suspend fun upsertAll(tasks: List) @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 是否按预期运行。

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

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

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

首先创建一个失败测试。这可以验证测试是否实际运行,以及测试的对象及其依赖项是否正确。 @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 按钮以运行测试。

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

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

您应该会在测试结果窗口中看到测试失败并显示以下消息:expected: but was:。这是预期结果,因为数据库中的任务数量是 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 = accessMutex.withLock { delay(SERVICE_LATENCY_IN_MILLIS) return tasks } suspend fun saveTasks(newTasks: List) = accessMutex.withLock { delay(SERVICE_LATENCY_IN_MILLIS) tasks = newTasks } } private const val SERVICE_LATENCY_IN_MILLIS = 2000L

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

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

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

注意:在真实应用中,您还需要为远程数据源创建测试。请记住,测试应仅针对您自己的代码,而不是库提供的代码。

7. 创建任务存储库

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

DefaultTaskRepository 的依赖项。

图 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 { // 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.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }

要点:映射函数时,应以函数使用位置的边界为基础。在本例中,LocalTask.kt 是映射该类型的内部和外部函数的理想位置。

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

🤔 为什么需要将相同的字段从一种数据类型复制到另一种数据类型,而不是直接使用 LocalTask 呢?

原因如下:

分离关注点。LocalTask 主要关注的是任务在数据库中的存储方式,而且包含与其他架构层无关的额外信息(例如 @Entity Room 注解)。 提高灵活性。通过分离内部模型和外部模型,您可以获得灵活性。具体而言,您可以自由更改内部存储结构,而不会影响其他层。例如,如果您想将本地存储方式从 Room 改为 DataStore,可以直接更改,而不会破坏数据层 API。 在 observeAll 中使用您新创建的 toExternal 函数: fun observeAll(): Flow { 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 比较复杂,为什么不能直接使用 Dispatchers.Default 以硬编码的方式来执行呢?

这是因为,如果将其作为参数指定到存储库,测试时就有可能注入不同的调度程序。而一般情况下,建议的做法是在同一线程上执行所有指令,以确保行为的确定性。

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

创建并存储任务 现在,您已经有了任务 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 }

🤔 为什么不能像之前做的那样,使用 withContext 封装 insertTask 或 toLocalModel?

首先,insertTask 由 Room 库提供,它负责确保使用非界面线程。

其次,toLocalModel 是单个小对象的内存中副本。如果是受 CPU 或 I/O 限制,或者是对一组对象的操作,则应使用 withContext。

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

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

同步数据

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

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

操作类型

存储库方法

步骤

数据移动

加载

observeAll

从本地数据库加载数据

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

保存

createcomplete

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

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

刷新

refresh

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.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.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 秒。但在真实应用中,可能会长得多,甚至在没有网络连接时根本无法运行。

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

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

注意:还有一个更好的解决方案,就是使用 WorkManager 调度独立的网络同步对象。

将协程作用域作为参数添加到 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.

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

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

添加以下内容: class FakeTaskDao(initialTasks: List) : TaskDao { private val _tasks = initialTasks.toMutableList() private val tasksStream = MutableStateFlow(_tasks.toList()) override fun observeAll(): Flow = 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) { 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 内,添加以下内容。

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

部分测试数据。

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

要测试的对象: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

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

创建一个名为 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

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

创建一个名为 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 开始。这是用于显示应用中第一个界面(即当前所有活动任务的列表)的视图模型。

打开该类,将 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 { ... } } }

🤔 为什么不将其写成 complete(...) = viewModelScope.launch { ... }?

这会将 Job 返回给调用方,使得界面能够取消操作。但我们并不希望向调用方提供这个权限。

对 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. 屏幕截图:没有任务时,应用的任务界面。

点按右上角的三个点,然后按刷新。

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

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

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

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

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

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

应用的添加任务界面。

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

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

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

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

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

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

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

10. 恭喜!

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

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

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

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

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

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

git checkout main 后续步骤

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

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



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3