Now in Android `:core:data` 数据层模块全解析:离线优先仓库架构与模块依赖图详解
2026/9/13 21:28:00 网站建设 项目流程

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传递依赖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/目录里,接口与实现成对出现:

接口默认实现数据来源
TopicsRepositoryOfflineFirstTopicsRepositoryRoom + 网络
NewsRepositoryOfflineFirstNewsRepositoryRoom + 网络
UserDataRepositoryOfflineFirstUserDataRepositoryPreferences DataStore
RecentSearchRepositoryDefaultRecentSearchRepositoryRoom
SearchContentsRepositoryDefaultSearchContentsRepository网络 + DataStore
UserNewsResourceRepositoryCompositeUserNewsResourceRepository上述仓库组合

命名中的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.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中的bindsNetworkMonitorbinds),使得测试可以轻松替换为假实现。

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

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

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

其中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 同步。

【免费下载链接】nowinandroidA fully functional Android app built entirely with Kotlin and Jetpack Compose项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroid

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询