虽然 UI 层包含与 UI 相关的状态和 UI 逻辑,但数据层包含应用程序数据和业务逻辑。业务逻辑赋予您的应用价值——它由现实世界的业务规则组成,这些规则决定了如何创建、存储和更改应用程序数据。
这种关注点分离允许数据层在多个屏幕上使用,在应用的不同部分之间共享信息,并在 UI 之外复制业务逻辑以进行单元测试。有关数据层优势的更多信息,请查看体系结构概述页面。
数据层架构
数据层由存储库组成,每个存储库可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据创建一个存储库类。例如,您可以为与电影相关的数据创建MoviesRepository
类,或为与支付相关的数据创建PaymentsRepository
类。
存储库类负责以下任务
- 向应用的其余部分公开数据。
- 集中对数据的更改。
- 解决多个数据源之间的冲突。
- 从应用的其余部分抽象数据源。
- 包含业务逻辑。
每个数据源类都应负责处理仅一个数据源,该数据源可以是文件、网络源或本地数据库。数据源类是应用与系统之间进行数据操作的桥梁。
层次结构中的其他层永远不应该直接访问数据源;数据层的入口点始终是存储库类。状态持有者类(请参阅UI 层指南)或用例类(请参阅域层指南)永远不应该将数据源作为直接依赖项。使用存储库类作为入口点允许体系结构的不同层独立扩展。
此层公开的数据应为不可变的,这样其他类就无法篡改它,否则可能会使其值处于不一致的状态。不可变数据也可以由多个线程安全地处理。有关更多详细信息,请参阅线程部分。
遵循依赖项注入最佳实践,存储库在其构造函数中将数据源作为依赖项。
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }
公开 API
数据层中的类通常公开函数以执行一次性创建、读取、更新和删除 (CRUD) 调用,或接收一段时间内数据更改的通知。数据层应针对以下每种情况公开以下内容
- 一次性操作:数据层应在 Kotlin 中公开挂起函数;对于 Java 编程语言,数据层应公开提供回调以通知操作结果的函数,或 RxJava
Single
、Maybe
或Completable
类型。 - 接收一段时间内数据更改的通知:数据层应在 Kotlin 中公开流;对于 Java 编程语言,数据层应公开一个回调,该回调发出新数据,或 RxJava
Observable
或Flowable
类型。
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) {
val data: Flow<Example> = ...
suspend fun modifyData(example: Example) { ... }
}
本指南中的命名约定
在本指南中,存储库类以其负责的数据命名。约定如下
数据类型 + Repository。
例如:NewsRepository
、MoviesRepository
或 PaymentsRepository
。
数据源类以其负责的数据和使用的源命名。约定如下
数据类型 + 源类型 + DataSource。
对于数据类型,使用 Remote 或 Local 以更通用,因为实现可能会发生变化。例如:NewsRemoteDataSource
或 NewsLocalDataSource
。如果源很重要,为了更具体,请使用源的类型。例如:NewsNetworkDataSource
或 NewsDiskDataSource
。
不要根据实现细节命名数据源,例如 UserSharedPreferencesDataSource
,因为使用该数据源的存储库不应该知道数据是如何保存的。如果您遵循此规则,则可以更改数据源的实现(例如,从 SharedPreferences 迁移到 DataStore),而不会影响调用该源的层。
多级存储库
在某些涉及更复杂业务需求的情况下,存储库可能需要依赖其他存储库。这可能是因为所涉及的数据是来自多个数据源的聚合,或者因为责任需要封装在另一个存储库类中。
例如,处理用户身份验证数据的存储库 UserRepository
可以依赖其他存储库,例如 LoginRepository
和 RegistrationRepository
来满足其需求。
真相来源
重要的是,每个存储库都定义一个单一的真相来源。真相来源始终包含一致、正确和最新的数据。事实上,从存储库公开的数据应该始终是直接来自真相来源的数据。
真相来源可以是数据源(例如,数据库),甚至可以是存储库可能包含的内存缓存。存储库组合不同的数据源并解决数据源之间任何潜在的冲突,以便定期或由于用户输入事件更新单一真相来源。
应用程序中的不同存储库可能具有不同的真相来源。例如,LoginRepository
类可能会使用其缓存作为真相来源,而 PaymentsRepository
类可能会使用网络数据源。
为了提供离线优先支持,建议使用本地数据源(例如数据库)作为真相来源。
线程
调用数据源和存储库应该是主线程安全的——可以在主线程中安全地调用。这些类负责在执行长时间阻塞操作时将逻辑的执行移动到相应的线程。例如,对于数据源从文件读取或存储库对大型列表执行昂贵的过滤操作,都应该是主线程安全的。
请注意,大多数数据源都提供了主线程安全的 API,例如 Room、Retrofit 或 Ktor 提供的挂起方法调用。您的存储库可以在可用时利用这些 API。
要了解有关线程的更多信息,请参阅 后台处理指南。对于 Kotlin 用户,协程 是推荐的选择。有关 Java 编程语言的推荐选项,请参阅 在后台线程中运行 Android 任务。
生命周期
数据层中类的实例只要可以从垃圾回收根访问,就会一直保留在内存中——通常是通过在应用程序中的其他对象中被引用来实现的。
如果一个类包含内存数据(例如,缓存),您可能希望在特定时间段内重用该类的同一实例。这也被称为类的实例的生命周期。
如果类的职责对整个应用程序至关重要,您可以将该类的实例作用域到 Application
类。这使得实例遵循应用程序的生命周期。或者,如果您只需要在应用程序中的特定流程中重用同一实例(例如,注册或登录流程),则应将实例的作用域限定为拥有该流程生命周期的类。例如,您可以将包含内存数据的 RegistrationRepository
作用域限定到 RegistrationActivity
或注册流程的 导航图。
每个实例的生命周期是决定如何在应用程序中提供依赖项的关键因素。建议您遵循 依赖项注入 的最佳实践,其中依赖项得到管理并且可以作用域到依赖项容器。要了解有关 Android 中作用域的更多信息,请参阅 Android 和 Hilt 中的作用域 博客文章。
表示业务模型
您希望从数据层公开的数据模型可能是从不同数据源获取的信息的子集。理想情况下,不同的数据源(网络和本地)都应该只返回应用程序需要的信息;但情况并非总是如此。
例如,假设一个新闻 API 服务器不仅返回文章信息,还返回编辑历史记录、用户评论和一些元数据
data class ArticleApiModel(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val modifications: Array<ArticleApiModel>,
val comments: Array<CommentApiModel>,
val lastModificationDate: Date,
val authorId: Long,
val authorName: String,
val authorDateOfBirth: Date,
val readTimeMin: Int
)
应用程序不需要那么多关于文章的信息,因为它只在屏幕上显示文章的内容以及有关其作者的基本信息。最好将模型类分开,并让您的存储库只公开其他层级所需的那些数据。例如,以下是如何从网络中修剪 ArticleApiModel
以便向域层和 UI 层公开 Article
模型类
data class Article(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val authorName: String,
val readTimeMin: Int
)
分离模型类的好处如下
- 通过将数据减少到仅需要的数据来节省应用程序内存。
- 它使外部数据类型适应应用程序使用的类型——例如,您的应用程序可能使用不同的数据类型来表示日期。
- 它提供了更好的关注点分离——例如,如果事先定义了模型类,大型团队的成员可以单独处理功能的网络层和 UI 层。
您也可以扩展此实践,并在应用程序体系结构的其他部分(例如,在数据源类和 ViewModel 中)定义单独的模型类。但是,这需要您定义额外的类和逻辑,您应该适当地记录和测试这些类和逻辑。至少,建议您在数据源接收与应用程序其余部分期望不匹配的数据的任何情况下创建新模型。
数据操作类型
数据层可以处理根据其重要性而变化的操作类型:面向 UI、面向应用程序和面向业务的操作。
面向 UI 的操作
面向 UI 的操作仅在用户位于特定屏幕上时才相关,并且当用户离开该屏幕时会被取消。一个例子是从数据库获取一些数据并显示。
面向 UI 的操作通常由 UI 层触发并遵循调用者的生命周期——例如,ViewModel 的生命周期。有关面向 UI 操作的示例,请参阅 发出网络请求 部分。
面向应用程序的操作
面向应用程序的操作只要应用程序处于打开状态就相关。如果应用程序关闭或进程被终止,则这些操作会被取消。一个示例是缓存网络请求的结果,以便以后需要时可以使用。请参阅 实现内存数据缓存 部分以了解更多信息。
这些操作通常遵循 Application
类或数据层生命周期。有关示例,请参阅 使操作的生命周期比屏幕更长 部分。
面向业务的操作
面向业务的操作无法取消。它们应该在进程死亡后继续存在。一个示例是完成用户想要发布到其个人资料的照片上传。
对于面向业务的操作,建议使用 WorkManager。请参阅 使用 WorkManager 调度任务 部分以了解更多信息。
公开错误
与存储库和数据源的交互可能会成功,也可能在发生故障时抛出异常。对于协程和流,您应该使用 Kotlin 的 内置错误处理机制。对于可能由挂起函数触发的错误,请在适当的时候使用 try/catch
块;在流中,使用 catch
运算符。使用此方法,UI 层在调用数据层时应处理异常。
数据层可以理解和处理不同类型的错误,并使用自定义异常公开它们——例如,UserNotAuthenticatedException
。
要了解有关协程中错误的更多信息,请参阅协程中的异常博文。
常见任务
以下部分提供了有关如何使用和构建数据层的示例,以执行 Android 应用中常见的某些任务。这些示例基于指南前面提到的典型新闻应用。
发出网络请求
发出网络请求是 Android 应用可能执行的最常见任务之一。新闻应用需要向用户展示从网络获取的最新新闻。因此,应用需要一个数据源类来管理网络操作:NewsRemoteDataSource
。为了将信息公开给应用的其余部分,创建了一个处理新闻数据操作的新存储库:NewsRepository
。
要求是,当用户打开屏幕时,始终需要更新最新新闻。因此,这是一个面向 UI 的操作。
创建数据源
数据源需要公开一个函数,该函数返回最新新闻:ArticleHeadline
实例列表。数据源需要提供一种主线程安全的方式来从网络获取最新新闻。为此,它需要依赖于CoroutineDispatcher
或Executor
来运行该任务。
发出网络请求是一个由新的fetchLatestNews()
方法处理的一次性调用。
class NewsRemoteDataSource(
private val newsApi: NewsApi,
private val ioDispatcher: CoroutineDispatcher
) {
/**
* Fetches the latest news from the network and returns the result.
* This executes on an IO-optimized thread pool, the function is main-safe.
*/
suspend fun fetchLatestNews(): List<ArticleHeadline> =
// Move the execution to an IO-optimized thread since the ApiService
// doesn't support coroutines and makes synchronous requests.
withContext(ioDispatcher) {
newsApi.fetchLatestNews()
}
}
// Makes news-related network synchronous requests.
interface NewsApi {
fun fetchLatestNews(): List<ArticleHeadline>
}
NewsApi
接口隐藏了网络 API 客户端的实现;无论接口是否由Retrofit或HttpURLConnection
支持,都没有区别。依赖于接口可以使您在应用中交换 API 实现。
创建存储库
由于此任务在存储库类中不需要额外的逻辑,因此NewsRepository
充当网络数据源的代理。在内存缓存部分中解释了添加此额外抽象层的好处。
// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
suspend fun fetchLatestNews(): List<ArticleHeadline> =
newsRemoteDataSource.fetchLatestNews()
}
要了解如何直接从 UI 层使用存储库类,请参阅UI 层指南。
实现内存数据缓存
假设新闻应用引入了新的需求:当用户打开屏幕时,如果之前已发出请求,则必须向用户展示缓存的新闻。否则,应用应发出网络请求以获取最新新闻。
鉴于新的需求,应用必须在用户打开应用时将最新新闻保留在内存中。因此,这是一个面向应用的操作。
缓存
您可以通过添加内存数据缓存来保留用户在应用中的数据。缓存旨在在内存中保存某些信息一段时间——在本例中,只要用户在应用中即可。缓存实现可以采用不同的形式。它可以从简单的可变变量到更复杂的类,以保护多线程上的读/写操作。根据用例,可以在存储库或数据源类中实现缓存。
缓存网络请求的结果
为简单起见,NewsRepository
使用可变变量来缓存最新新闻。为了保护来自不同线程的读写操作,使用了Mutex
。要了解有关共享可变状态和并发性的更多信息,请参阅Kotlin 文档。
以下实现将最新新闻信息缓存到存储库中的一个变量,该变量使用Mutex
进行写保护。如果网络请求的结果成功,则将数据分配给latestNews
变量。
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
// Mutex to make writes to cached values thread-safe.
private val latestNewsMutex = Mutex()
// Cache of the latest news got from the network.
private var latestNews: List<ArticleHeadline> = emptyList()
suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
if (refresh || latestNews.isEmpty()) {
val networkResult = newsRemoteDataSource.fetchLatestNews()
// Thread-safe write to latestNews
latestNewsMutex.withLock {
this.latestNews = networkResult
}
}
return latestNewsMutex.withLock { this.latestNews }
}
}
使操作的持续时间长于屏幕
如果用户在网络请求正在进行时导航离开屏幕,则该请求将被取消,并且结果不会被缓存。NewsRepository
不应使用调用方的CoroutineScope
来执行此逻辑。相反,NewsRepository
应使用与其生命周期关联的CoroutineScope
。获取最新新闻需要成为面向应用的操作。
为了遵循依赖项注入最佳实践,NewsRepository
应在其构造函数中接收范围作为参数,而不是创建自己的CoroutineScope
。由于存储库应在后台线程中执行大部分工作,因此您应使用Dispatchers.Default
或您自己的线程池配置CoroutineScope
。
class NewsRepository(
...,
// This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
private val externalScope: CoroutineScope
) { ... }
由于NewsRepository
已准备好使用外部CoroutineScope
执行面向应用的操作,因此它必须执行对数据源的调用并使用该范围启动的新协程保存其结果。
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource,
private val externalScope: CoroutineScope
) {
/* ... */
suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
return if (refresh) {
externalScope.async {
newsRemoteDataSource.fetchLatestNews().also { networkResult ->
// Thread-safe write to latestNews.
latestNewsMutex.withLock {
latestNews = networkResult
}
}
}.await()
} else {
return latestNewsMutex.withLock { this.latestNews }
}
}
}
async
用于在外部范围内启动协程。await
在新的协程上调用,以挂起直到网络请求返回并将结果保存到缓存。如果到那时用户仍在屏幕上,则他们将看到最新新闻;如果用户离开屏幕,则await
会被取消,但async
内部的逻辑将继续执行。
请参阅这篇博文,以了解有关CoroutineScope
模式的更多信息。
保存和检索磁盘上的数据
假设您想保存书签新闻和用户首选项等数据。此类型的数据需要在进程死亡后继续存在,即使用户未连接到网络也能访问。
如果您正在处理的数据需要在进程死亡后继续存在,则需要通过以下方式之一将其存储在磁盘上。
- 对于需要查询、需要引用完整性或需要部分更新的大型数据集,请将数据保存在Room 数据库中。在新闻应用示例中,可以将新闻文章或作者保存在数据库中。
- 对于只需要检索和设置(不查询或部分更新)的小型数据集,请使用DataStore。在新闻应用示例中,可以将用户的首选日期格式或其他显示首选项保存在 DataStore 中。
- 对于数据块(如 JSON 对象),请使用文件。
如真相来源部分所述,每个数据源仅使用一个来源并对应于特定数据类型(例如News
、Authors
、NewsAndAuthors
或UserPreferences
)。使用数据源的类不应该知道数据是如何保存的——例如,在数据库中还是在文件中。
Room 作为数据源
由于每个数据源都应负责仅使用一个特定数据类型的来源,因此 Room 数据源将接收数据访问对象 (DAO)或数据库本身作为参数。例如,NewsLocalDataSource
可能会将NewsDao
的实例作为参数,而AuthorsLocalDataSource
可能会将AuthorsDao
的实例作为参数。
在某些情况下,如果不需要额外的逻辑,您可以将 DAO 直接注入到存储库中,因为 DAO 是一个接口,您可以在测试中轻松替换它。
要了解有关使用 Room API 的更多信息,请参阅Room 指南。
DataStore 作为数据源
DataStore非常适合存储键值对,例如用户设置。示例可能包括时间格式、通知首选项以及在用户阅读新闻项目后是否显示或隐藏它们。DataStore 还可以使用协议缓冲区存储类型化对象。
与任何其他对象一样,由 DataStore 支持的数据源应包含对应于特定类型或应用特定部分的数据。对于 DataStore 而言,这一点尤其重要,因为 DataStore 读取以流的形式公开,每次更新值时都会发出信号。因此,您应该在同一个 DataStore 中存储相关首选项。
例如,您可以拥有一个NotificationsDataStore
,它只处理与通知相关的首选项,以及一个NewsPreferencesDataStore
,它只处理与新闻屏幕相关的首选项。这样,您就可以更好地限定更新范围,因为newsScreenPreferencesDataStore.data
流仅在更改与该屏幕相关的首选项时发出信号。这也意味着对象的生存期可以更短,因为它只需在显示新闻屏幕时存在即可。
要了解有关使用 DataStore API 的更多信息,请参阅DataStore 指南。
文件作为数据源
在处理大型对象(如 JSON 对象或位图)时,您需要使用File
对象并处理线程切换。
要了解有关使用文件存储的更多信息,请参阅存储概述页面。
使用 WorkManager 计划任务
假设新闻应用引入了另一个新的需求:应用必须允许用户定期自动获取最新新闻,只要设备正在充电并连接到非计量网络即可。这使得它成为一个面向业务的操作。此需求使即使设备在用户打开应用时没有连接性,用户仍然可以看到最新的新闻。
WorkManager 可以轻松调度异步且可靠的工作,并能处理约束管理。它是执行持久性工作的推荐库。为了执行上面定义的任务,创建了一个 Worker
类:RefreshLatestNewsWorker
。此类将 NewsRepository
作为依赖项,以便获取最新新闻并将其缓存到磁盘。
class RefreshLatestNewsWorker(
private val newsRepository: NewsRepository,
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
newsRepository.refreshLatestNews()
Result.success()
} catch (error: Throwable) {
Result.failure()
}
}
此类任务的业务逻辑应封装在自己的类中,并视为单独的数据源。然后,WorkManager 只负责确保在满足所有约束条件时在后台线程上执行工作。通过遵守此模式,可以根据需要快速在不同的环境中交换实现。
在此示例中,此与新闻相关的任务必须从 NewsRepository
中调用,后者将采用新的数据源作为依赖项:NewsTasksDataSource
,其实现如下
private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"
class NewsTasksDataSource(
private val workManager: WorkManager
) {
fun fetchNewsPeriodically() {
val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
REFRESH_RATE_HOURS, TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
.setRequiresCharging(true)
.build()
)
.addTag(TAG_FETCH_LATEST_NEWS)
workManager.enqueueUniquePeriodicWork(
FETCH_LATEST_NEWS_TASK,
ExistingPeriodicWorkPolicy.KEEP,
fetchNewsRequest.build()
)
}
fun cancelFetchingNewsPeriodically() {
workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
}
}
这些类型的类以其负责的数据命名,例如 NewsTasksDataSource
或 PaymentsTasksDataSource
。与特定类型数据相关的所有任务都应封装在同一类中。
如果需要在应用启动时触发任务,建议使用 应用启动 库从 Initializer
中触发 WorkManager 请求。
要了解有关使用 WorkManager API 的更多信息,请参阅 WorkManager 指南。
测试
依赖注入 最佳实践有助于测试您的应用。依靠与外部资源通信的类的接口也很有帮助。当您测试一个单元时,可以注入其依赖项的模拟版本,以使测试确定性和可靠性。
单元测试
常规测试指南 适用于测试数据层。对于单元测试,在需要时使用真实对象,并模拟任何访问外部源(例如从文件读取或从网络读取)的依赖项。
集成测试
访问外部源的集成测试往往不太确定,因为它们需要在真实设备上运行。建议在受控环境下执行这些测试,以提高集成测试的可靠性。
对于数据库,Room 允许创建可在测试中完全控制的内存数据库。要了解更多信息,请参阅 测试和调试数据库 页面。
对于网络,有一些流行的库,例如 WireMock 或 MockWebServer,它们允许您模拟 HTTP 和 HTTPS 调用并验证请求是否按预期发出。
示例
以下 Google 示例演示了数据层的用法。探索它们以了解此指南的实践。