数据层

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. 数据层在应用架构中的作用。

存储库类负责以下任务:

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

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

层次结构中的其他层不应直接访问数据源;数据层的入口点始终是存储库类。状态容器类(请参阅界面层指南)或用例类(请参阅领域层指南)不应将数据源作为直接依赖项。使用存储库类作为入口点可让架构的不同层独立扩缩。

此层公开的数据应不可变,这样其他类就无法随意更改其值,否则可能会导致其值处于不一致的状态。不可变数据也可以由多个线程安全地处理。如需了解更多详情,请参阅线程处理部分

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

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

公开 API

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

  • 一次性操作:数据层应在 Kotlin 中公开 suspend 函数;对于 Java 编程语言,数据层应公开提供回调以通知操作结果的函数,或 RxJava SingleMaybeCompletable 类型。
  • 随时间变化的数据更改通知:数据层应在 Kotlin 中公开 Flow;对于 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

对于数据类型,请使用远程 (Remote) 或本地 (Local) 以使其更具通用性,因为实现可能会更改。例如: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 提供的 suspend 方法调用。您的存储库可以在这些 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)中定义单独的模型类。但是,这需要您定义额外的类和逻辑,您应该妥善地文档化和测试它们。至少,建议您在数据源接收到的数据与应用其余部分预期不符的任何情况下,创建新的模型。

数据操作类型

数据层可以处理各种操作类型,这些操作类型根据其重要性而异:界面导向型、应用导向型和业务导向型操作。

界面导向型操作

界面导向型操作仅在用户位于特定屏幕上时相关,当用户离开该屏幕时,这些操作就会被取消。一个示例是显示从数据库获取的一些数据。

界面导向型操作通常由界面层触发,并遵循调用者的生命周期,例如 ViewModel 的生命周期。有关界面导向型操作的示例,请参阅发起网络请求部分。

应用导向型操作

只要应用处于打开状态,应用导向型操作就相关。如果应用关闭或进程被终止,这些操作就会被取消。一个示例是缓存网络请求的结果,以便在需要时稍后使用。如需了解更多信息,请参阅实现内存数据缓存部分。

这些操作通常遵循 Application 类或数据层的生命周期。有关示例,请参阅使操作的生命周期长于屏幕部分。

业务导向型操作

业务导向型操作无法取消。它们应在进程终止后继续存在。一个示例是完成用户要发布到其个人资料的照片上传。

业务导向型操作的建议是使用 WorkManager。如需了解更多信息,请参阅使用 WorkManager 安排任务部分。

公开错误

与存储库和数据源的交互可以成功,或者在发生故障时抛出异常。对于协程和 Flow,您应该使用 Kotlin 的内置错误处理机制。对于可能由 suspend 函数触发的错误,请在适当情况下使用 try/catch 块;在 Flow 中,使用 catch 运算符。采用这种方法,界面层在调用数据层时应处理异常。

数据层可以理解和处理不同类型的错误,并使用自定义异常公开它们,例如 UserNotAuthenticatedException

如需详细了解协程中的错误,请参阅协程中的异常博文。

常见任务

以下部分介绍了如何使用数据层并对其进行架构以执行 Android 应用中常见的某些任务的示例。这些示例基于本指南前面提到的典型新闻应用。

发起网络请求

发起网络请求是 Android 应用可能执行的最常见任务之一。新闻应用需要向用户展示从网络获取的最新新闻。因此,应用需要一个数据源类来管理网络操作:NewsRemoteDataSource。为了向应用的其余部分公开信息,创建了一个处理新闻数据操作的新存储库:NewsRepository

要求是,当用户打开屏幕时,最新新闻始终需要更新。因此,这是一种界面导向型操作

创建数据源

数据源需要公开一个函数,该函数返回最新新闻: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()
}

如需了解如何直接从界面层使用存储库类,请参阅界面层指南。

实现内存数据缓存

假设新闻应用引入了一项新要求:当用户打开屏幕时,如果之前已发出请求,则必须向用户显示缓存的新闻。否则,应用应发起网络请求以获取最新新闻。

鉴于新要求,应用必须在用户打开应用时在内存中保留最新新闻。因此,这是一种应用导向型操作

缓存

通过添加内存数据缓存,可以在用户使用应用时保留数据。缓存旨在在内存中保存特定时间的信息(在本例中为用户使用应用期间)。缓存实现可以采用不同的形式。它可以从简单的可变变量到更复杂的类,以防止在多个线程上进行读/写操作。根据用例,缓存可以在存储库或数据源类中实现。

缓存网络请求的结果

为简单起见,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 读取以 Flow 形式公开,每次值更新时都会发出。因此,您应该将相关的偏好设置存储在同一个 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 调用,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。与特定数据类型相关的所有任务都应封装在同一个类中。

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

如需详细了解如何使用 WorkManager API,请参阅 WorkManager 指南

测试

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

单元测试

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

集成测试

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

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

对于网络,有 WireMockMockWebServer 等流行库,它们允许您模拟 HTTP 和 HTTPS 调用,并验证请求是否按预期进行。

示例

以下 Google 示例演示了数据层的使用。欢迎探索它们,了解此指南的实际应用: