Now in Android `:core:data` 数据层模块全解析:离线优先仓库架构与模块依赖图详解
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传递依赖:common、database、datastore、network用api(...)暴露给下游。因为:core:data的公开 Repository 接口签名里会出现这些模块的类型(如NiaNetworkDataSource、DAO 查询结果),下游 feature 模块必须能解析这些类型,所以采用传递依赖。implementation私有依赖:analytics、notifications仅被实现类内部使用(如OfflineFirstUserDataRepository上报埋点、OfflineFirstNewsRepository发通知),不进入公开 API 面,因此用implementation隔离,避免下游模块反向耦合。- 测试基建:
isIncludeAndroidResources = true允许 Robolectric 环境加载资源;测试依赖core.testing(提供TestDispatcherRule等)与core.datastoreTest(提供测试用 DataStore 实现),对应test/目录下的TestSynchronizer.kt、testdoubles/中的TestNewsResourceDao、TestTopicDao、TestNiaNetworkDataSource等测试替身。
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 三步走:
- fetch:读本地已同步版本号,向服务端请求该版本之后的变更列表(
NetworkChangeList,含id、isDelete、changeListVersion字段); - apply:将变更列表
partition为"已删除"与"已更新"两组,先删后改; - 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 的
RecentSearchQueryDao,insertOrReplaceRecentSearch用Clock.System.now()记录查询时间,getRecentSearchQueries(limit)返回按时间倒序的最近搜索(UI 层限制展示条数),clearRecentSearches一键清空。 DefaultSearchContentsRepository:负责搜索内容的组装。CompositeUserNewsResourceRepository:聚合"用户数据 + 新闻 + 话题"三个仓库,为 For You 页输出合并后的UserNewsResource流(对应 CompositeUserNewsResourceRepositoryTest.kt 与UserNewsResourceTest.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.kt 与
ConnectivityManagerNetworkMonitor:把ConnectivityManager的网络状态封装为Flow<Boolean>,供同步逻辑判断是否具备联网条件; - TimeZoneMonitor.kt 与
TimeZoneBroadcastMonitor:监听ACTION_TIMEZONE_CHANGED广播,供需要按本地时间展示的新闻内容刷新数据; SyncManager:同步调度入口,被sync/work模块的 WorkManager 任务调用。
这些工具类同样遵循"接口 + 默认实现 + Hilt 绑定"的模式(见上文 DataModule 中的 bindsNetworkMonitor 与 binds),使得测试可以轻松替换为假实现。
测试体系:离线优先行为的可验证性
core/data 的 test/ 目录完整覆盖了上述仓库的离线行为:
| 测试文件 | 验证目标 |
|---|---|
| OfflineFirstNewsRepositoryTest.kt | 新闻变更列表同步、分批 upsert、首次同步标记已读、通知触发 |
| OfflineFirstTopicsRepositoryTest.kt | 话题同步与版本号更新 |
| OfflineFirstUserDataRepositoryTest.kt | 用户偏好写路径与埋点调用 |
CompositeUserNewsResourceRepositoryTest.kt、UserNewsResourceTest.kt |
For You 组合数据流 |
| TestSynchronizer.kt | 测试用 Synchronizer 替身 |
testdoubles/TestNewsResourceDao.kt、TestTopicDao.kt、TestNiaNetworkDataSource.kt |
DAO 与网络数据源的内存假实现 |
其中 TestNiaNetworkDataSource 让测试无需真实网络即可模拟"服务端变更列表",TestSynchronizer 则让测试可以精确控制 ChangeListVersions 的读写——这正是 changeListSync 把"版本读写"与"数据拉取"解耦后带来的可测性红利。
总结
回到那张依赖图::core:data 是整个 Now in Android 数据访问的"唯一门面"。它在编译期依赖 common/database/datastore/network,在运行时协作 analytics/notifications,对外只暴露语义化的 Repository 接口与 Flow 数据流。其核心价值可归纳为三点:
- 离线优先:所有读操作只走本地(Room / DataStore),网络仅作为写路径的增量同步来源,保证弱网甚至断网场景下的可用性;
- 增量同步:
changeListSync以版本号为水位线的类 git 机制,配合分批拉取、外键顺序与首次同步去噪,兼顾正确性与性能; - 可替换性:接口 + 实现 + Hilt 绑定 + 测试替身的组合,使每个仓库都能在测试中独立验证,也让后续替换存储介质(如换用 Paging、引入 Remote Mediator)不影响上层调用方。
如果要在仓库中继续深挖,推荐从三个入口入手:先读 core/data/README.md 建立依赖全貌,再对照 DataModule.kt 梳理接口实现映射,最后以 OfflineFirstNewsRepositoryTest 为模板,理解如何用测试替身驱动一次完整的 change list 同步。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.23 K637- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python530
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2.01 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python51072
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go22145
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java35251