首页
/ Now in Android `:core:data` 数据层模块全解析:离线优先仓库架构与模块依赖图详解

Now in Android `:core:data` 数据层模块全解析:离线优先仓库架构与模块依赖图详解

2026-09-12 11:27:06作者:裘晴惠Vivianne

core/data/README.md 是 Now in Android 项目中 :core:data 模块的"架构说明书",它以一张模块依赖图定义了该模块在整个分层架构中的位置:数据层被夹在 :core:database / :core:datastore / :core:network 等基础设施之上,向 feature 层屏蔽底层数据来源细节。本文以该依赖图为骨架,结合模块内 Repository 接口与实现、changeListSync 同步机制、Hilt 依赖注入以及单元测试源码,完整还原这个"离线优先"数据层的设计思路与落地代码,读完你既能看懂这张图,也能直接在仓库中定位每一个关键类。

模块定位:依赖图揭示的分层职责

原文档开篇即用一张 Mermaid 依赖图说明 :core:data:core 分组中的位置。图中实线箭头表示编译期硬依赖,虚线箭头表示运行时/弱依赖(如仅在特定构建变体或执行时机才触达):

---
config:
  layout: elk
  elk:
    nodePlacementStrategy: SIMPLE
---
graph TB
  subgraph :core
    direction TB
    :core:analytics[analytics]:::android-library
    :core:common[common]:::jvm-library
    :core:data[data]:::android-library
    :core:database[database]:::android-library
    :core:datastore[datastore]:::android-library
    :core:datastore-proto[datastore-proto]:::jvm-library
    :core:model[model]:::jvm-library
    :core:network[network]:::android-library
    :core:notifications[notifications]:::android-library
  end

  :core:data -.-> :core:analytics
  :core:data --> :core:common
  :core:data --> :core:database
  :core:data --> :core:datastore
  :core:data --> :core:network
  :core:data -.-> :core:notifications
  :core:database --> :core:model
  :core:datastore -.-> :core:common
  :core:datastore --> :core:datastore-proto
  :core:datastore --> :core:model
  :core:network --> :core:common
  :core:network --> :core:model
  :core:notifications -.-> :core:common
  :core:notifications --> :core:model

图例约定:android-application(绿)、android-feature(橙)、android-library(蓝)、android-test(蓝紫)、jvm-library(紫)、unknown(红),其中 library --> jvm 表示 Android 库可以依赖纯 JVM 库(如 :core:model:core:common)。

:core:data 相关的边翻译成依赖语义表:

依赖边 类型 含义
:core:data --> :core:common 实线 编译期依赖公共工具与协程扩展
:core:data --> :core:database 实线 依赖 Room 数据库(DAO 与 Entity)
:core:data --> :core:datastore 实线 依赖 Preferences DataStore 用户偏好
:core:data --> :core:network 实线 依赖网络数据源获取远端数据
:core:data -.-> :core:analytics 虚线 运行时调用埋点上报
:core:data -.-> :core:notifications 虚线 运行时触达本地通知

从这张图可以读出 :core:data 的核心设计原则:它把"数据库 + DataStore + 网络 + 埋点 + 通知"组装成一组面向业务语义的 Repository,向上层只暴露干净的接口:core:model 不被 :core:data 直接依赖,而是经由 :core:database / :core:datastore / :core:network 间接提供纯 Kotlin 领域模型,避免了数据层与 UI 层在模型上的耦合。

构建配置:api 与 implementation 的依赖纪律

依赖图在 Gradle 中的落地见 core/data/build.gradle.kts

plugins {
    alias(libs.plugins.nowinandroid.android.library)
    alias(libs.plugins.nowinandroid.android.library.jacoco)
    alias(libs.plugins.nowinandroid.hilt)
    id("kotlinx-serialization")
}

android {
    namespace = "com.google.samples.apps.nowinandroid.core.data"
    testOptions.unitTests.isIncludeAndroidResources = true
}

dependencies {
    api(projects.core.common)
    api(projects.core.database)
    api(projects.core.datastore)
    api(projects.core.network)

    implementation(projects.core.analytics)
    implementation(projects.core.notifications)

    testImplementation(libs.kotlinx.coroutines.test)
    testImplementation(libs.kotlinx.serialization.json)
    testImplementation(projects.core.datastoreTest)
    testImplementation(projects.core.testing)
}

几个值得注意的工程决策:

  • api 传递依赖commondatabasedatastorenetworkapi(...) 暴露给下游。因为 :core:data 的公开 Repository 接口签名里会出现这些模块的类型(如 NiaNetworkDataSource、DAO 查询结果),下游 feature 模块必须能解析这些类型,所以采用传递依赖。
  • implementation 私有依赖analyticsnotifications 仅被实现类内部使用(如 OfflineFirstUserDataRepository 上报埋点、OfflineFirstNewsRepository 发通知),不进入公开 API 面,因此用 implementation 隔离,避免下游模块反向耦合。
  • 测试基建isIncludeAndroidResources = true 允许 Robolectric 环境加载资源;测试依赖 core.testing(提供 TestDispatcherRule 等)与 core.datastoreTest(提供测试用 DataStore 实现),对应 test/ 目录下的 TestSynchronizer.kttestdoubles/ 中的 TestNewsResourceDaoTestTopicDaoTestNiaNetworkDataSource 等测试替身。

Repository 架构:接口与实现的"离线优先"配对

模块内源码结构(core/data/src/main/kotlin)分为 di/model/repository/util/ 四块。repository/ 目录里,接口与实现成对出现:

接口 默认实现 数据来源
TopicsRepository OfflineFirstTopicsRepository Room + 网络
NewsRepository OfflineFirstNewsRepository Room + 网络
UserDataRepository OfflineFirstUserDataRepository Preferences DataStore
RecentSearchRepository DefaultRecentSearchRepository Room
SearchContentsRepository DefaultSearchContentsRepository 网络 + DataStore
UserNewsResourceRepository CompositeUserNewsResourceRepository 上述仓库组合

命名中的 OfflineFirst 是核心设计信号的直接体现。以 OfflineFirstTopicsRepository.kt 为例,类注释明确写着:

"Disk storage backed implementation of the TopicsRepository. Reads are exclusively from local storage to support offline access."

读操作只走本地磁盘

internal class OfflineFirstTopicsRepository @Inject constructor(
    private val topicDao: TopicDao,
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopics(): Flow<List<Topic>> =
        topicDao.getTopicEntities()
            .map { it.map(TopicEntity::asExternalModel) }

    override fun getTopic(id: String): Flow<Topic> =
        topicDao.getTopicEntity(id).map { it.asExternalModel() }
}

查询结果从 Room 的 Flow 中流出,再由 asExternalModel() 把数据库 Entity 映射为 :core:model 中的纯领域模型 Topic。这意味着 UI 层永远不需要直接碰 DAO 或网络层——只要数据库里有数据,即使断网也能完整渲染。

OfflineFirstNewsRepository.kt 的读路径同理,但它额外支持 NewsResourceQuery 条件过滤:

override fun getNewsResources(
    query: NewsResourceQuery,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
    useFilterTopicIds = query.filterTopicIds != null,
    filterTopicIds = query.filterTopicIds ?: emptySet(),
    useFilterNewsIds = query.filterNewsIds != null,
    filterNewsIds = query.filterNewsIds ?: emptySet(),
)
    .map { it.map(PopulatedNewsResource::asExternalModel) }

useFilterXxx 布尔开关用于让 Room 在"是否启用该过滤条件"之间切换查询分支,从而用同一个方法服务"全部新闻流""按关注话题过滤""按 ID 集合过滤"等多种场景。

变更列表同步:changeListSync 的类 git 机制

写路径是离线优先架构的关键:数据必须通过网络增量同步进本地库。core/data 用一套名为 change list sync 的机制完成这件事,定义在 SyncUtilities.kt

  • Synchronizer:管理 ChangeListVersions(各模型的版本号)的读写,并提供 Syncable.sync() 语法糖;
  • Syncable:声明 suspend fun syncWith(synchronizer: Synchronizer): Boolean,是所有可同步仓库的标记接口;
  • changeListSync(...):通用的同步编排函数,注释里用 git 做了非常形象的类比。
suspend fun Synchronizer.changeListSync(
    versionReader: (ChangeListVersions) -> Int,
    changeListFetcher: suspend (Int) -> List<NetworkChangeList>,
    versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
    modelDeleter: suspend (List<String>) -> Unit,
    modelUpdater: suspend (List<String>) -> Unit,
) = suspendRunCatching {
    // Fetch the change list since last sync (akin to a git fetch)
    val currentVersion = versionReader(getChangeListVersions())
    val changeList = changeListFetcher(currentVersion)
    if (changeList.isEmpty()) return@suspendRunCatching true

    val (deleted, updated) = changeList.partition(NetworkChangeList::isDelete)

    // Delete models that have been deleted server-side
    modelDeleter(deleted.map(NetworkChangeList::id))

    // Using the change list, pull down and save the changes (akin to a git pull)
    modelUpdater(updated.map(NetworkChangeList::id))

    // Update the last synced version (akin to updating local git HEAD)
    val latestVersion = changeList.last().changeListVersion
    updateChangeListVersions {
        versionUpdater(latestVersion)
    }
}.isSuccess

完整流程对应 git 三步走:

  1. fetch:读本地已同步版本号,向服务端请求该版本之后的变更列表(NetworkChangeList,含 idisDeletechangeListVersion 字段);
  2. apply:将变更列表 partition 为"已删除"与"已更新"两组,先删后改;
  3. commit:用变更列表末条的 changeListVersion 更新本地版本号,作为下次增量同步的水位线。

同步失败时,suspendRunCatching 会捕获非协程取消类异常并返回 Result.failure(同时通过 Log.i 记录),由 changeListSync 收敛为 false。这里特意重抛 CancellationException,避免破坏结构化并发。

OfflineFirstTopicsRepository.syncWith 是它的最简用法:版本号存于 ChangeListVersions::topicVersion,变更通过 topicDao.upsertTopics 落库。而 OfflineFirstNewsRepository.syncWith 则复杂得多,包含了三个工程化细节:

  • 分批拉取SYNC_BATCH_SIZE = 40,注释说明这是为了平衡服务端与客户端的序列化/反序列化成本;
  • 外键顺序:代码注释强调 "Order of invocation matters to satisfy id and foreign key constraints!"——必须先 topicDao.insertOrIgnoreTopics 插入话题实体,再 upsertNewsResources 插入新闻,最后 insertOrIgnoreTopicCrossRefEntities 建立多对多关联,否则会违反 Room 外键约束;
  • 首次同步去噪isFirstSync = currentVersion <= 0 时,将首批历史新闻全部标记为已读(setNewsResourcesViewed(changedIds, true)),避免新用户被海量历史通知淹没;已完引导(shouldHideOnboarding)的用户,若其关注话题下出现新增新闻,则通过 notifier.postNewsNotifications 触发本地通知。

这一整套同步调用链由 util/SyncManager.kt 调度,由 sync/work 模块的 WorkManager 任务触发。

用户数据仓库:DataStore 偏好与埋点上报

OfflineFirstUserDataRepository.kt 是所有"用户个性化状态"的汇聚点,它不做持久化,而是把写操作委托给 NiaPreferencesDataSource(Preferences DataStore),并同步调用 AnalyticsHelper 埋点:

override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) {
    niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed)
    analyticsHelper.logTopicFollowToggled(followedTopicId, followed)
}

override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
    niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked)
    analyticsHelper.logNewsResourceBookmarkToggled(
        newsResourceId = newsResourceId,
        isBookmarked = bookmarked,
    )
}

override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
    niaPreferencesDataSource.setThemeBrand(themeBrand)
    analyticsHelper.logThemeChanged(themeBrand.name)
}

其公开接口 UserDataRepository.kt 覆盖了 Now in Android 的全部用户可配置状态:关注话题集合、新闻收藏/已读、主题品牌(ThemeBrand)、深色模式(DarkThemeConfig)、动态取色开关以及引导完成状态。读端则以 val userData: Flow<UserData> 单一数据流对外暴露,UI 层通过 combine 订阅后即可响应所有偏好变化。

值得注意的实现细节是 setFollowedTopicIds 标了 @VisibleForTesting,只用于测试或批量恢复场景,业务路径上使用的是单个话题的 setTopicIdFollowed

搜索与组合仓库

针对"搜索"与"For You"页,core/data 额外提供了两组仓库:

  • DefaultRecentSearchRepository.kt:基于 Room 的 RecentSearchQueryDaoinsertOrReplaceRecentSearchClock.System.now() 记录查询时间,getRecentSearchQueries(limit) 返回按时间倒序的最近搜索(UI 层限制展示条数),clearRecentSearches 一键清空。
  • DefaultSearchContentsRepository:负责搜索内容的组装。
  • CompositeUserNewsResourceRepository:聚合"用户数据 + 新闻 + 话题"三个仓库,为 For You 页输出合并后的 UserNewsResource 流(对应 CompositeUserNewsResourceRepositoryTest.ktUserNewsResourceTest.kt 中的大量测试用例)。

依赖注入:DataModule 把接口与实现绑在一起

core/data 采用 Hilt 注入,所有绑定集中在 di/DataModule.kt

@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {

    @Binds
    internal abstract fun bindsTopicRepository(
        topicsRepository: OfflineFirstTopicsRepository,
    ): TopicsRepository

    @Binds
    internal abstract fun bindsNewsResourceRepository(
        newsRepository: OfflineFirstNewsRepository,
    ): NewsRepository

    @Binds
    internal abstract fun bindsUserDataRepository(
        userDataRepository: OfflineFirstUserDataRepository,
    ): UserDataRepository

    @Binds
    internal abstract fun bindsRecentSearchRepository(
        recentSearchRepository: DefaultRecentSearchRepository,
    ): RecentSearchRepository

    @Binds
    internal abstract fun bindsSearchContentsRepository(
        searchContentsRepository: DefaultSearchContentsRepository,
    ): SearchContentsRepository

    @Binds
    internal abstract fun bindsNetworkMonitor(
        networkMonitor: ConnectivityManagerNetworkMonitor,
    ): NetworkMonitor

    @Binds
    internal abstract fun binds(impl: TimeZoneBroadcastMonitor): TimeZoneMonitor
}

@Binds 的绑定全部是 internal 且依赖抽象接口,业务代码只感知接口、不知道实现存在。此外,di/UserNewsResourceRepositoryModule.kt 单独成模块,把 CompositeUserNewsResourceRepository 绑定到 UserNewsResourceRepository,体现了"一个领域一个绑定模块"的 Hilt 组织方式。

工具类:网络监听与时区感知

util/ 目录下的三个组件为仓库和同步服务提供系统能力抽象:

  • NetworkMonitor.ktConnectivityManagerNetworkMonitor:把 ConnectivityManager 的网络状态封装为 Flow<Boolean>,供同步逻辑判断是否具备联网条件;
  • TimeZoneMonitor.ktTimeZoneBroadcastMonitor:监听 ACTION_TIMEZONE_CHANGED 广播,供需要按本地时间展示的新闻内容刷新数据;
  • SyncManager:同步调度入口,被 sync/work 模块的 WorkManager 任务调用。

这些工具类同样遵循"接口 + 默认实现 + Hilt 绑定"的模式(见上文 DataModule 中的 bindsNetworkMonitorbinds),使得测试可以轻松替换为假实现。

测试体系:离线优先行为的可验证性

core/datatest/ 目录完整覆盖了上述仓库的离线行为:

测试文件 验证目标
OfflineFirstNewsRepositoryTest.kt 新闻变更列表同步、分批 upsert、首次同步标记已读、通知触发
OfflineFirstTopicsRepositoryTest.kt 话题同步与版本号更新
OfflineFirstUserDataRepositoryTest.kt 用户偏好写路径与埋点调用
CompositeUserNewsResourceRepositoryTest.ktUserNewsResourceTest.kt For You 组合数据流
TestSynchronizer.kt 测试用 Synchronizer 替身
testdoubles/TestNewsResourceDao.ktTestTopicDao.ktTestNiaNetworkDataSource.kt DAO 与网络数据源的内存假实现

其中 TestNiaNetworkDataSource 让测试无需真实网络即可模拟"服务端变更列表",TestSynchronizer 则让测试可以精确控制 ChangeListVersions 的读写——这正是 changeListSync 把"版本读写"与"数据拉取"解耦后带来的可测性红利。

总结

回到那张依赖图::core:data 是整个 Now in Android 数据访问的"唯一门面"。它在编译期依赖 common/database/datastore/network,在运行时协作 analytics/notifications,对外只暴露语义化的 Repository 接口与 Flow 数据流。其核心价值可归纳为三点:

  1. 离线优先:所有读操作只走本地(Room / DataStore),网络仅作为写路径的增量同步来源,保证弱网甚至断网场景下的可用性;
  2. 增量同步changeListSync 以版本号为水位线的类 git 机制,配合分批拉取、外键顺序与首次同步去噪,兼顾正确性与性能;
  3. 可替换性:接口 + 实现 + Hilt 绑定 + 测试替身的组合,使每个仓库都能在测试中独立验证,也让后续替换存储介质(如换用 Paging、引入 Remote Mediator)不影响上层调用方。

如果要在仓库中继续深挖,推荐从三个入口入手:先读 core/data/README.md 建立依赖全貌,再对照 DataModule.kt 梳理接口实现映射,最后以 OfflineFirstNewsRepositoryTest 为模板,理解如何用测试替身驱动一次完整的 change list 同步。

登录后查看全文
热门项目推荐
相关项目推荐