数据层

虽然 UI 层包含与 UI 相关的状态和 UI 逻辑,但数据层包含应用程序数据业务逻辑。业务逻辑是为您的应用提供价值的关键——它由现实世界的业务规则组成,这些规则决定了如何创建、存储和更改应用程序数据。

这种关注点分离允许数据层在多个屏幕上使用,在应用的不同部分之间共享信息,并在 UI 之外重现业务逻辑以进行单元测试。有关数据层优势的更多信息,请查看架构概述页面

数据层架构

数据层由存储库组成,每个存储库可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据创建一个存储库类。例如,您可以为与电影相关的数据创建MoviesRepository类,或为与付款相关的数据创建PaymentsRepository类。

In a typical architecture, the data layer's repositories provide data
    to the rest of the app and depend on the data sources.
图 1. 数据层在应用架构中的作用。

存储库类负责以下任务

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

每个数据源类都应负责处理一个数据源,该数据源可以是文件、网络源或本地数据库。数据源类是应用与系统之间进行数据操作的桥梁。

层次结构中的其他层永远不应该直接访问数据源;数据层的入口点始终是存储库类。状态持有者类(请参阅UI 层指南)或用例类(请参阅域层指南)永远不应该将数据源作为直接依赖项。使用存储库类作为入口点允许架构的不同层独立扩展。

此层公开的数据应为不可变的,这样其他类就无法对其进行篡改,否则可能会导致其值处于不一致状态。不可变数据也可以由多个线程安全地处理。有关更多详细信息,请参阅线程部分

遵循依赖项注入最佳实践,存储库在构造函数中将数据源作为依赖项。

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

公开 API

数据层中的类通常会公开函数来执行一次性创建、读取、更新和删除 (CRUD) 调用,或在数据随时间变化时收到通知。对于每种情况,数据层都应公开以下内容

  • 一次性操作:对于 Kotlin,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开提供回调以通知操作结果的函数,或 RxJava 的 SingleMaybeCompletable 类型。
  • 在数据随时间变化时收到通知:对于 Kotlin,数据层应公开 ;对于 Java 编程语言,数据层应公开一个发出新数据的回调,或 RxJava 的 ObservableFlowable 类型。
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

本指南中的命名约定

在本指南中,存储库类以其负责的数据命名。约定如下

数据类型 + Repository

例如:NewsRepositoryMoviesRepositoryPaymentsRepository

数据源类以其负责的数据和使用的源命名。约定如下

数据类型 + 源类型 + DataSource

对于数据类型,使用 RemoteLocal 以更通用,因为实现可能会更改。例如:NewsRemoteDataSourceNewsLocalDataSource。如果源很重要,为了更具体,请使用源的类型。例如:NewsNetworkDataSourceNewsDiskDataSource

不要根据实现细节来命名数据源,例如 UserSharedPreferencesDataSource,因为使用该数据源的存储库不应该知道数据是如何保存的。如果您遵循此规则,则可以更改数据源的实现(例如,从 SharedPreferences 迁移到 DataStore),而不会影响调用该源的层。

多级存储库

在某些涉及更复杂业务需求的情况下,存储库可能需要依赖其他存储库。这可能是因为相关数据来自多个数据源的聚合,或者因为责任需要封装在另一个存储库类中。

例如,处理用户身份验证数据的存储库 UserRepository 可以依赖其他存储库,例如 LoginRepositoryRegistrationRepository 来满足其需求。

In the example, UserRepository depends on two other repository classes:
    LoginRepository, which depends on other login data sources; and
    RegistrationRepository, which depends on other registration data sources.
图 2. 依赖于其他存储库的存储库的依赖关系图。

真相来源

重要的是,每个存储库都定义一个单一的真相来源。真相来源始终包含一致、正确和最新的数据。实际上,存储库公开的数据应该始终是直接来自真相来源的数据。

真相来源可以是数据源(例如,数据库),甚至可以是存储库可能包含的内存缓存。存储库结合不同的数据源并解决数据源之间任何潜在的冲突,以定期更新单一真相来源或由于用户输入事件而更新。

应用程序中的不同存储库可能具有不同的真相来源。例如,LoginRepository 类可能会使用其缓存作为真相来源,而 PaymentsRepository 类可能会使用网络数据源。

为了提供离线优先支持,建议将本地数据源(例如数据库)作为真相来源

线程

调用数据源和存储库应该主线程安全——可以在主线程中安全调用。这些类负责在执行长时间运行的阻塞操作时将逻辑的执行移动到相应的线程。例如,对于数据源从文件中读取数据或存储库对大型列表执行昂贵的过滤操作,都应该是主线程安全的。

请注意,大多数数据源已经提供了主线程安全的 API,例如 RoomRetrofitKtor 提供的挂起方法调用。您的存储库可以在可用时利用这些 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实例。数据源需要提供一种主线程安全的方式来从网络获取最新的新闻。为此,它需要依赖CoroutineDispatcherExecutor来运行任务。

发出网络请求是一个由新的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 对象),请使用文件

真相来源部分所述,每个数据源仅使用一个来源并对应于特定数据类型(例如NewsAuthorsNewsAndAuthorsUserPreferences)。使用数据源的类不应该知道数据是如何保存的——例如,保存在数据库中还是保存在文件中。

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

这些类型的类以它们负责的数据命名,例如 NewsTasksDataSourcePaymentsTasksDataSource。与特定类型数据相关的所有任务都应封装在同一个类中。

如果需要在应用启动时触发任务,建议使用 应用启动 库从 Initializer 调用存储库来触发 WorkManager 请求。

要了解有关使用 WorkManager API 的更多信息,请参阅 WorkManager 指南

测试

依赖项注入 的最佳实践有助于测试您的应用。依靠与外部资源通信的类的接口也很有帮助。当您测试一个单元时,您可以注入其依赖项的伪造版本,以使测试确定性和可靠性。

单元测试

通用测试指南 适用于测试数据层。对于单元测试,在需要时使用真实对象,并伪造任何访问外部源的依赖项,例如从文件读取或从网络读取。

集成测试

访问外部源的集成测试往往不太确定,因为它们需要在真实设备上运行。建议您在受控环境下执行这些测试,以提高集成测试的可靠性。

对于数据库,Room 允许创建可以在测试中完全控制的内存数据库。要了解更多信息,请参阅 测试和调试数据库 页面。

对于网络,有一些流行的库,例如 WireMockMockWebServer,它们允许您伪造 HTTP 和 HTTPS 调用并验证请求是否按预期发出。

示例

以下 Google 示例演示了数据层的用法。请浏览它们以了解此指南的实际应用。