【现代 Android APP 架构】03. Data 层的拆分逻辑

400 阅读11分钟

关于时间分配:

1/3 用于计划

1/6 用于编码

1/4 用于构件测试和早期系统测试

1/4 用于系统测试,此时所有的构件已就绪

——《人月神话》

数据层所处的位置与职责

UI 层包含了 UI 状态视图逻辑,与之对应的,数据(Data)层则由 应用数据业务逻辑 两部分组成。根据 关注点分离 的原则,数据层本身只关心数据的来源与业务逻辑,它是与具体的 UI 实现 解耦 的,同样一套数据层模块,应当既可以应用于 Android,也可以应用在 iOS、H5 上面。此外,数据层也应当具备良好的单元测试兼容性。

数据层划分的依据是 业务上的数据定义,例如对于大麦网 APP来说,ShowRepository用来提供演出数据接口,PaymentsRepository则可以用作支付方式的数据源。

数据层在应用内部所处的位置如下:

数据层由数据仓库(Data Repository)和数据源(Data Source)两者组成,通常来说,数据层应当提供以下功能:

  • 对 APP 内部其他模块 提供数据 ,这也是数据层最基本的职责
  • 对 APP 内部其他模块 隐藏数据的真实来源
  • 汇聚所有数据变更,对外部而言所看到的都是不可变的数据
  • 处理多个数据源产生的数据冲突,对于多端登录的场景尤为重要
  • 处理业务逻辑

数据源(Data Source)则代表了数据的真实来源,它可以是文件(File)、网络(Network)或者本地数据库(Database)。数据源将APP与操作系统之间的数据传输进行 抽象,在其中充当 桥梁 的作用。

数据层所提供的数据必须是不可变(immutable)的。这样有两点好处:其一是防止外部多处修改数据产生冲突,其二是不可变的数据对于多线程环境更加友好。

一个典型的基于依赖反转的数据源接口设计如下。

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

对于功能简单、模块数量较少的APP,一个常见做法是将 Data SourceData Repository 合二为一,尤其是对于只有一类 Data Source 的情况。

对外接口设计

数据层最主要的职责是对外提供数据操作,包含增查改删(Create, Read, Update, Delete) 在内。根据 是否为即时返回,可以分为两类实现:

  • 瞬时操作:
    • Kotlin:通过挂起函数(suspend function)返回结果
    • Java:通过回调(Callback)通知结果
    • RxJava:通过SingleMaybeCompletable 获取结果
  • 耗时操作:
    • Kotlin:通过Flow返回结果
    • Java:通过回调(Callback)通知结果
    • RxJava:通过ObservableFlowable返回

是否为即时返回 是一种比较粗粒度的划分,Google 官方也没有明确提出多久的延迟算作“即时”,这里通常是借助经验来判断。个人认为,可以用500ms作为门槛,<500ms 的(例如内存运算)采用 suspend 函数,>500ms 的(例如文件操作、网络、数据库读写)则使用 Flow

数据层模块命名

Repository 命名

数据类型 + Repository,例如NewsRepositoryMoviesRepositoryPaymentsRepository,请注意这里采用的都是复数的命名方式。

Data Source 命名

数据类型 + 本地/远程 + DataSource,例如NewsRemoteDataSourceNewsLocalDataSource

反面教材: 用具体实现来命名 DataSource 是不可取的,这样暴露了模块内部的细节,不利于后续扩展和改写。一个典型反例就是 UserSharedPreferencesDataSource

多层级 Repository

依据关注点分离的原则,根据软件业务逻辑的复杂程度,可以设计不同的 Repositories 之间的依赖关系,此时应当注意最小化对外暴露的接口。

线程管理

数据仓库对外暴露的接口应当是主线程安全(main-safe)的,外部包括 UI 层尽管在需要的地方调用,将线程切换的工作封装在数据仓库内部来做。

如前文所言,Kotlin 提供了较多的机制来降低线程切换的成本,常见的有 suspend functionflows

业务模型设计

这其实是一个有待讨论的议题,“到底是使用原始的数据最齐全的 Model,还是使用经过裁剪的针对具体业务设计的 Model”

Options使用原始 Model使用裁剪Model
优点代码文件数量少关注点分离
缺点关注点未分离,返回大量冗余数据要创建大量 Model 文件

个人倾向于使用 裁剪的 Model,针对不同的模块间接口,设计相应的 Model 类。例如一个完整的 ArticleApiModel 类,会包含文章全部元数据:

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
)

而对于APP而言,只需要关心文章的标题、内容、发布日期、作者等信息,其它的诸如评论、作者生日、预估阅读时长等,在 UI 层是不需要的。此时,可以将 Model 裁剪成为 Article 类,它的字段更精简,含义也更明确。

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

通过这样裁剪,可以达到:

  • 精简对象,降低内存占用。
  • 充当适配器(Adapter)功能,将原始数据源类型进行转化。
  • 提供更好的关注点分离实践。

数据的生命周期

从数据活跃的生命周期角度,可以分为三类。

  • 面相 UI 的数据: 仅当用户处于某一特定页面时有效,当用户离开页面后,该数据即过期作废。典型例子是跟 ViewModel 生命周期绑定在一起的数据操作,当 VM 销毁(通常是用户退出页面)后,数据当即失效。
  • 面向 APP 的数据: 数据的生命周期在 APP 进程存活期间持续存在,例如内存缓存。
  • 面向业务逻辑的数据: 即使 APP 进程被杀,数据仍然有效,典型例子是通过 WorkManager 启动的后台任务,例如上传用户本地图片等。

异常传递

数据层读写发生异常时,优先采用抛出异常的处理手段。对于 suspend function 来说,使用 try/catch 进行捕获;对于 Flow 而言,则使用 catch 操作符。

另一种处理异常结果的方式是使用 Result<T> 包装类,对数据处理进行二次包装,例如可以使用密封类 Error 继承自 Result。建议读者仅在 异常传递机制 不生效的场景下,再考虑采用这种包装类的形式。

常见需求点的设计实现

数据源的设计和实现

在设计一个新闻流数据源时,考虑以下几点因素:

  1. 数据来自于云端服务器
  2. 通过注解实现依赖反转
  3. 获取新闻列表是耗时操作,需在IO线程运行
  4. 考虑到未来可能替换掉网络实现&单元测试需求,将服务器接口抽象出来
class NewsRemoteDataSource( // ===> 1. 数据来自于云端服务器
  private val newsApi: NewsApi, // ===> 2. 通过注解实现依赖反转
  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) { // ===> 3. 获取新闻列表是耗时操作,需在IO线程运行
            newsApi.fetchLatestNews()
        }
    }

// Makes news-related network synchronous requests.
interface NewsApi { // ===> 4. 考虑到未来可能替换掉网络实现&单元测试需求,将服务器接口抽象出来
    fun fetchLatestNews(): List<ArticleHeadline>
}

实现网络请求的内存缓存

第二次打开页面时,即时加载缓存数据

当用户处在 APP 的使用过程中,应当构建一种内存缓存,以实现对于打开过的页面,再次进入时先展示内存缓存数据的效果。

出于读写安全的考虑,这里使用 Mutex 加锁。

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()
            // ===> 写加锁
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        // ===> 读加锁
        return latestNewsMutex.withLock { this.latestNews }
    }
}

扩展网络请求的生命周期——使用外部传入的 Scope

期望当前页面退出时,仍然将处理中的网络请求在后台执行完成(注意防止内存泄漏),此时应当使用外部传入的 CoroutineScope,因为它具有更长的生命周期,是 APP 级别的数据,而非页面级别的数据。

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    // ===> 使用 SupervisorJob 隔断异常透传
    private val externalScope: CoroutineScope
) { ... }

完整的实现如下。

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async { // ===> 基于外部 CoroutineScope 启动协程
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await() // ===> await阻塞直至结果返回,
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        } 
    }
}

本地数据存储的选择——Database、DataStore 和 File

  • 对于需要查询、引用完整性或部分更新的大型数据集 ,请将数据保存在 Room 数据库中。在新闻 APP 中,新闻文章或作者可以保存在数据库中。
  • 对于只需要检索和设置(无需查询或部分更新)的小型数据集,使用 DataStore。在新闻 APP 中,用户的首选日期格式或其他显示偏好设置可以保存在 DataStore 中。
  • 对于像 JSON 对象这样的数据块,使用 File

使用 DataStore 保存少量数据

关于 DataStore: 是 Google 推荐的现代数据存储解决方案,通过协程异步实现,具备天然非阻塞特性。并且具备强类型支持,对复杂数据也可以通过 Protobuf 进行结构化。DataStore 是对小型数据集进行读写的优先选择。

DataStore 代码示例
val Context.dataStore by preferencesDataStore(name = "settings")
dataStore.edit { prefs ->
    prefs[PreferencesKeys.DARK_MODE] = true
}

// ===> 通过 Flow 监听数据
dataStore.data
    .map { prefs -> prefs[PreferencesKeys.DARK_MODE] ?: false }
    .collect { mode -> updateUi(mode) }
DataStore 与 SharedPreference 之间对比
特性SharedPreferencesDataStore
线程模型主线程可能阻塞(ANR 风险)协程异步,天然非阻塞
数据一致性弱一致性(apply 异步)事务性原子操作
类型安全强类型(Preferences/Proto
复杂数据支持仅基本类型支持 Protobuf 结构化数据
错误处理无内置机制通过 Flow 的 catch 处理异常
迁移成本支持从 SharedPreferences 迁移

如果想了解更多,可以查阅 DataStore 使用指南。

使用 Room 数据库保存大量数据

关于这部分知识,可以查阅 Room 使用指南。

使用 File 保存文件

关于这部分知识,可以查阅 File 使用指南。

使用 WorkManager 在进程未启动时执行后台任务

这个场景在常规产品需求中出现较少,但仍然有讨论和学习的价值。根据设备状态的不同(是否连接网络、是否充电),设计自动拉取新闻流的任务。WorkManager 就是为了实现这种异步且考虑多种条件后执行的任务而设计的,尤其是后台数据同步

考虑这样一个场景,当用户手机在充电&已连接网络时,即使没有启动我们的新闻 APP,仍然希望可以在后台拉取最新的新闻列表并且进行缓存,这样下次用户启动应用时就可以做到秒开,且展示的是最新数据。

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

在设计好 RefreshLatestNewsWorker 以后,可以将它封装成 NewsTasksDataSource,交给 NewsRepository 进行管理。例如,用户可以在“个人设置”页选择是否开启空闲时更新文章的功能,如果选择“开启”,则调用 fetchNewsPeriodically 进行 WorkManager 注册,如果关闭,则调用 cancelFetchingNewsPeriodically 进行注销。

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

如果想查阅更多关于 WorkManager 的知识,可参考 WorkManager 使用指南。

测试

最后是对于数据层的测试部分,由于脱离了 UI 的具体实现,数据层是最容易进行 mock 测试的模块。同时,出于数据一致性、安全性、完整性的考虑,数据层也是最应该进行单元测试的模块。

由于我们在早期设计里使用了依赖注入,因此可以很方便地进行具体实现的替换,从而进行测试。

单元测试

使用 Fake 数据源,对文件、网络请求进行模拟。

集成测试

集成测试需要运行在真实设备上,尤其需要注意设备环境的一致性,使测试过程更加可信。

对于数据库,Room 支持创建一个仅存在于内存的数据库镜像,用于在不影响真实数据的前提下进行模拟。

对于网络请求,可以使用 WireMockMockWebServer 这样的工具,对 HTTP、HTTPS请求进行模拟。

参考资料