Now in Android:core:data数据层模块全解析:离线优先仓库架构与模块依赖图详解
【免费下载链接】nowinandroidA fully functional Android app built entirely with Kotlin and Jetpack Compose项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroid
core/data/README.md是 Now in Android 项目中:core:data模块的"架构说明书",它以一张模块依赖图定义了该模块在整个分层架构中的位置:数据层被夹在:core:database/:core:datastore/:core:network等基础设施之上,向feature层屏蔽底层数据来源细节。本文以该依赖图为骨架,结合模块内 Repository 接口与实现、changeListSync同步机制、Hilt 依赖注入以及单元测试源码,完整还原这个"离线优先"数据层的设计思路与落地代码,读完你既能看懂这张图,也能直接在仓库中定位每一个关键类。
模块定位:依赖图揭示的分层职责
原文档开篇即用一张 Mermaid 依赖图说明:core:data在:core分组中的位置。图中实线箭头表示编译期硬依赖,虚线箭头表示运行时/弱依赖(如仅在特定构建变体或执行时机才触达):
图例约定: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 同步。
【免费下载链接】nowinandroidA fully functional Android app built entirely with Kotlin and Jetpack Compose项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroid
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考