Now in Android 架构学习之旅:三层架构、单向数据流与实战源码解析
本文是 Now in Android 官方开源示例应用的架构学习指南,系统讲解其分层架构(数据层、领域层、UI 层)、关键类及层与层之间的交互方式。通过阅读本文,你将掌握 Now in Android 如何落地官方 Android 架构指南、如何用 Kotlin Flow 实现单向数据流(UDF),并能对照仓库源码逐行理解"For You 屏幕展示新闻"的完整数据链路。
架构目标与需求
Now in Android 对应用架构设定了明确的目标,这也是后续一切设计决策的出发点:
- 尽可能贴近官方架构指南的推荐做法;
- 易于开发者理解,不引入过于实验性的方案;
- 支持多位开发者同时在同一个代码库上协作;
- 同时便利本地测试与仪器测试(Instrumented Tests),既可在开发者本机运行,也可接入持续集成(CI);
- 尽量缩短构建时间。
这些目标共同塑造了 Now in Android 采用"官方推荐的三层架构 + 响应式单向数据流"的形态——既不过度复杂,又足以作为生产级应用的学习范本。
架构总览:三层架构与单向数据流
Now in Android 的架构包含三个层次:数据层(Data layer)、领域层(Domain layer) 与 UI 层(UI layer),它们共同遵循 Android 官方架构指南的组织方式。
[!NOTE] 官方 Android 架构与其他架构(例如 "Clean Architecture")并不相同,其他架构中的概念在这里可能不适用,或以不同的方式被应用。
该架构采用响应式编程模型,实现了单向数据流(Unidirectional Data Flow, UDF)。数据层位于底部,核心概念可以概括为三句话:
- 高层对低层的变化做出反应(Higher layers react to changes in lower layers);
- 事件向下流动(Events flow down);
- 数据向上流动(Data flows up)。
数据流动依赖流(Streams)机制,在 Kotlin 中即通过 Kotlin Flows 实现。这意味着 UI 不会主动"拉取"数据,而是订阅低层数据流,一旦数据发生变化便自动收到最新值。
示例:For You 屏幕展示新闻
当应用首次运行时,它会尝试从远程服务器加载一批新闻资源(仅在 prod 构建变体下如此;demo 构建使用本地数据)。加载完成后,应用根据用户选择的兴趣主题(Topics)向用户展示这些新闻。
下面的时序图展示了这一过程中发生的事件,以及数据如何在相关对象之间流动:
以下是每一步的详细说明。在仓库中定位对应代码最便捷的方式,是把项目导入 Android Studio,然后用"双击 ⇧ SHIFT"搜索 Code 列中的文本。
| 步骤 | 描述 | 代码 |
|---|---|---|
| 1 | 应用启动时,入队一个用于同步所有仓库(Repository)的 WorkManager 任务 | Sync.initialize |
| 2 | ForYouViewModel 调用 GetUserNewsResourcesUseCase,获取带有书签/已保存状态的新闻资源流。直到用户仓库(user repository)与新闻仓库(news repository)都发出数据,该流中才会有数据。等待期间,feed 状态被置为 Loading |
搜索 NewsFeedUiState.Loading 的使用处 |
| 3 | 用户数据仓库从基于 Proto DataStore 的本地数据源获取 UserData 对象流 |
NiaPreferencesDataSource.userData |
| 4 | WorkManager 执行同步任务,调用 OfflineFirstNewsRepository 开始与远程数据源同步数据 |
SyncWorker.doWork |
| 5 | OfflineFirstNewsRepository 调用 RetrofitNiaNetwork,使用 Retrofit 执行实际的 API 请求 |
OfflineFirstNewsRepository.syncWith |
| 6 | RetrofitNiaNetwork 调用远程服务器上的 REST API |
RetrofitNiaNetwork.getNewsResources |
| 7 | RetrofitNiaNetwork 收到远程服务器的网络响应 |
RetrofitNiaNetwork.getNewsResources |
| 8 | OfflineFirstNewsRepository 通过 NewsResourceDao 同步远程数据——在本地 Room 数据库中插入、更新或删除数据 |
OfflineFirstNewsRepository.syncWith |
| 9 | 当 NewsResourceDao 中的数据发生变化时,变化被发射到新闻资源数据流中(该流是一个 Flow) |
NewsResourceDao.getNewsResources |
| 10 | OfflineFirstNewsRepository 作为该流的中间操作符,将流入的 PopulatedNewsResource(数据层内部使用的数据库模型)转换为供其他层消费的公开模型 NewsResource |
OfflineFirstNewsRepository.getNewsResources |
| 11 | GetUserNewsResourcesUseCase 将新闻资源列表与用户数据组合,发射出 UserNewsResource 列表 |
GetUserNewsResourcesUseCase.invoke |
| 12 | 当 ForYouViewModel 收到可保存的新闻资源时,将 feed 状态更新为 Success;ForYouScreen 随后使用状态中的新闻资源渲染屏幕 |
搜索 NewsFeedUiState.Success 的实例 |
这 12 步完整展示了"事件向下流、数据向上流"的闭环:用户启动应用(事件)→ 触发同步(向下流向数据层)→ 数据经 Room、仓库、用例逐层向上转换 → 最终以 UI 状态的形式渲染到屏幕。
数据层(Data layer)
数据层被实现为应用数据与业务逻辑的离线优先(offline-first)来源,是整个应用中所有数据的单一事实来源(source of truth)。
每个仓库(Repository)拥有自己的模型。例如 TopicsRepository 拥有 Topic 模型,NewsRepository 拥有 NewsResource 模型。
仓库是其他层访问数据的公开 API,是访问应用数据的唯一途径。仓库通常提供一个或多个读写数据的方法。
读取数据
数据以数据流的形式暴露。这意味着仓库的每个调用方都必须准备好对数据变化做出反应。数据不会以快照(snapshot)形式暴露(例如 getModel() 这种一次性返回),因为无法保证快照在使用时仍然有效。
读取操作以本地存储作为单一事实来源,因此从 Repository 实例读取数据时通常不会出错。不过,在将本地存储与远程源进行数据对账(reconcile)时可能会出错,详见下文"数据同步"小节。
示例:读取主题列表
订阅 TopicsRepository::getTopics 流即可获得 List<Topic>。每当主题列表发生变化(例如新增了一个主题),更新后的 List<Topic> 会被发射到流中。对应的真实实现见 OfflineFirstTopicsRepository.kt:
override fun getTopics(): Flow<List<Topic>> =
topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) }
注意其中的数据转换:DAO 返回的是数据库实体 TopicEntity,仓库通过 asExternalModel() 将其映射为领域/UI 层消费的 Topic 模型——这正是第 10 步描述的"数据层内部模型与公开模型隔离"的体现。
写入数据
写入数据时,仓库提供的是挂起函数(suspend functions)。是否让这些函数在合适的协程作用域(scope)内执行,由调用方负责。
示例:关注一个主题
只需调用 UserDataRepository.toggleFollowedTopicId,传入用户想要关注的主题 ID,并设置 followed=true 表示关注(传 false 表示取消关注)。写入结果会通过 DataStore 持久化,并作为 userData 流的一部分重新发射给订阅者。
数据源(Data sources)
一个仓库可能依赖一个或多个数据源。例如,OfflineFirstTopicsRepository 依赖以下数据源:
| 名称 | 底层技术 | 用途 |
|---|---|---|
TopicsDao |
Room/SQLite | 与主题(Topics)相关的持久化关系型数据 |
NiaPreferencesDataSource |
Proto DataStore | 与用户偏好相关的持久化非结构化数据,具体而言是用户感兴趣的主题列表;该数据用 protobuf 语法在 .proto 文件中定义与建模 |
NiaNetworkDataSource |
使用 Retrofit 访问的远程 API | 通过 REST API 端点以 JSON 形式提供的主题数据 |
其中 NiaPreferencesDataSource 的实现位于 core/datastore:它把 DataStore 中存储的 protobuf 消息(UserPreferences)映射为应用模型 UserData,其中包含书签新闻 ID、已浏览新闻 ID、已关注主题 ID、主题品牌、深色主题配置、动态取色开关、是否隐藏引导页等字段。
三种数据源的分工清晰:Room 管关系型业务数据,DataStore 管用户偏好,Retrofit 管远程数据,仓库负责把它们整合为对外一致的 API。
数据同步
仓库负责将本地存储与远程源进行对账(reconcile)。一旦从远程数据源取得数据,会立即写入本地存储,更新后的数据从本地存储(Room)发射到相应的数据流中,被所有监听的客户端接收。
这种做法的好处是:应用的读与写关注点相互分离、互不干扰——读永远只发生在本地(快、可离线、可预测),写(同步)在后台异步完成。
数据同步期间若发生错误,会采用**指数退避(exponential backoff)**策略。这一策略委托给 WorkManager,通过 SyncWorker(Synchronizer 接口的实现)完成。SyncWorker 的入口见 SyncWorker.kt,其核心逻辑是:
override suspend fun doWork(): Result = withContext(ioDispatcher) {
traceAsync("Sync", 0) {
analyticsHelper.logSyncStarted()
syncSubscriber.subscribe()
// 先并行同步各个仓库
val syncedSuccessfully = awaitAll(
async { topicRepository.sync() },
async { newsRepository.sync() },
).all { it }
analyticsHelper.logSyncFinished(syncedSuccessfully)
if (syncedSuccessfully) {
searchContentsRepository.populateFtsData()
Result.success()
} else {
Result.retry() // 失败时交给 WorkManager 按指数退避重试
}
}
}
可以观察到几个关键设计:主题仓库与新闻仓库的同步并行执行(awaitAll + async);同步失败时返回 Result.retry(),由 WorkManager 负责指数退避重试;同步成功后才回填全文搜索(FTS)数据;启动同步通过 startUpSyncWork() 以**加急一次性任务(expedited one-time work)**入队,并附带网络约束(SyncConstraints)。
数据同步的典型实现可以参见 OfflineFirstNewsRepository.syncWith(OfflineFirstNewsRepository.kt)。其中几个值得学习的工程细节:
- 使用
changeListSync增量同步:传入versionReader(读取当前版本号)、changeListFetcher(按版本拉取变更列表)、versionUpdater(更新版本号)、modelDeleter(删除失效模型)与modelUpdater(按 ID 批量更新模型); - 变更 ID 按
SYNC_BATCH_SIZE = 40分批拉取,兼顾客户端与服务端的序列化/反序列化成本; - 更新时严格遵循外键约束的写入顺序:先
insertOrIgnoreTopics写入主题,再upsertNewsResources写入新闻,最后insertOrIgnoreTopicCrossRefEntities写入多对多关联表; - 首次同步时将所有历史新闻标记为"已浏览",避免通知轰炸;只有已引导(onboarded)的用户才会触发新增新闻的系统通知。
领域层(Domain layer)
领域层包含用例(Use Cases)。用例是拥有单个可调用方法(operator fun invoke)并包含业务逻辑的类。
用例用于简化和消除 ViewModel 中的重复逻辑,它们通常负责组合与转换来自仓库的数据。
例如,GetUserNewsResourcesUseCase 将一个来自 NewsRepository 的 NewsResource 流(基于 Flow)与一个来自 UserDataRepository 的 UserData 流组合,生成 UserNewsResource 流。该流被多个 ViewModel 使用,用于在屏幕上展示带有书签状态的新闻资源。
仓库中领域层位于 core/domain,其下包含 GetFollowableTopicsUseCase、GetRecentSearchQueriesUseCase、GetSearchContentsUseCase 等用例。以 GetFollowableTopicsUseCase.kt 为例,可以看到"组合两个流"的标准写法:
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> = combine(
userDataRepository.userData,
topicsRepository.getTopics(),
) { userData, topics ->
val followedTopics = topics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics,
)
}
when (sortBy) {
NAME -> followedTopics.sortedBy { it.topic.name }
else -> followedTopics
}
}
用例把"主题列表"与"用户已关注的主题集合"两个独立的数据流用 combine 合并,产出携带关注状态的 FollowableTopic 列表,并支持按名称排序(TopicSortField.NAME)或不排序(TopicSortField.NONE)。
值得强调的是,Now in Android 的领域层目前不包含任何用于事件处理的用例。事件由 UI 层直接调用仓库的方法来处理。
UI 层(UI layer)
UI 层由以下部分组成:
- 使用 Jetpack Compose 构建的 UI 元素;
- Android ViewModels。
ViewModel 从用例和仓库接收数据流,并将它们转换为 UI 状态。UI 元素反映这一状态,并给用户提供交互方式;这些交互作为事件传递给 ViewModel 处理。
建模 UI 状态
UI 状态使用接口与不可变数据类建模为密封层级(sealed hierarchy)。状态对象只会在数据流转换过程中被发射。这种做法保证了:
- UI 状态始终代表底层应用数据——应用数据才是单一事实来源;
- UI 元素能够处理所有可能的状态。
示例:For You 屏幕的新闻 feed
For You 屏幕上的新闻 feed(列表)使用 NewsFeedUiState 建模。这是一个密封接口(sealed interface),创建了两个可能状态的层级:
Loading:表示数据正在加载;Success:表示数据加载成功,Success状态中包含新闻资源列表。
feedState 被传递给 ForYouScreen 这个 composable,它同时处理这两种状态。NewsFeedUiState 定义在 core/ui 中,ForYouScreen 对它的分支渲染逻辑可以在 ForYouScreen.kt 中找到。
将数据流转换为 UI 状态
ViewModel 从一个或多个用例或仓库接收作为冷流(cold flow)的数据,用 combine 组合,或用 map 转换,最终产出一个单一的 UI 状态流。这个单一流再通过 stateIn 转换为热流(hot flow)。转换为状态流(StateFlow)后,UI 元素可以从流中读取最后已知的状态。
示例:展示已关注的主题
InterestsViewModel 将 uiState 暴露为 StateFlow<InterestsUiState>。这个热流由 GetFollowableTopicsUseCase 提供的 List<FollowableTopic> 冷流创建:每当新的列表被发射,就转换为一个 InterestsUiState.Interests 状态暴露给 UI。
在 ForYouViewModel.kt 中可以看到 stateIn 的典型用法:对于深链新闻资源(deep link)观察,使用 SavedStateHandle.getStateFlow + flatMapLatest 组合,并用 SharingStarted.WhileSubscribed(5_000) 启动——即只有在存在订阅者时才启动上游流,并在最后一个订阅者消失 5 秒后自动停止,避免后台空转浪费资源。isSyncing(同步状态)同样通过 stateIn 暴露给 UI。
处理用户交互
用户操作通过普通方法调用从 UI 元素传递到 ViewModel。这些方法以 lambda 表达式的形式传递给 UI 元素。
示例:关注一个主题
InterestsScreen 接收一个名为 followTopic 的 lambda 表达式,它来自 InterestsViewModel.followTopic。每当用户点击某个主题想要关注时,该方法被调用。ViewModel 通过通知用户数据仓库来处理这个动作(写入 DataStore),随后更新的 userData 流会重新发射,驱动 GetFollowableTopicsUseCase 组合出新的 FollowableTopic 列表,UI 状态随之刷新——整个单向数据流闭环再次完成。
进一步学习
- Android 官方应用架构指南:涵盖数据层、领域层、UI 层以及 UI 状态与事件处理的权威定义;
- Jetpack Compose 官方文档:理解 UI 元素如何声明式地反映状态。
如果想继续深入,建议按顺序阅读仓库中与之配套的 ModularizationLearningJourney(模块化学习之旅),并在 feature/foryou、core/data 与 core/domain 三个目录中对照本文提到的类逐一阅读,即可完整掌握 Now in Android 的架构全貌。
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.22 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python430
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2.01 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python48868
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.Go21143
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java34551



