news 2026/9/13 18:03:23

Now in Android 架构学习之旅:三层架构、单向数据流与实战源码解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Now in Android 架构学习之旅:三层架构、单向数据流与实战源码解析

Now in Android 架构学习之旅:三层架构、单向数据流与实战源码解析

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

本文是 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
2ForYouViewModel调用GetUserNewsResourcesUseCase,获取带有书签/已保存状态的新闻资源流。直到用户仓库(user repository)与新闻仓库(news repository)都发出数据,该流中才会有数据。等待期间,feed 状态被置为Loading搜索NewsFeedUiState.Loading的使用处
3用户数据仓库从基于 Proto DataStore 的本地数据源获取UserData对象流NiaPreferencesDataSource.userData
4WorkManager 执行同步任务,调用OfflineFirstNewsRepository开始与远程数据源同步数据SyncWorker.doWork
5OfflineFirstNewsRepository调用RetrofitNiaNetwork,使用 Retrofit 执行实际的 API 请求OfflineFirstNewsRepository.syncWith
6RetrofitNiaNetwork调用远程服务器上的 REST APIRetrofitNiaNetwork.getNewsResources
7RetrofitNiaNetwork收到远程服务器的网络响应RetrofitNiaNetwork.getNewsResources
8OfflineFirstNewsRepository通过NewsResourceDao同步远程数据——在本地 Room 数据库中插入、更新或删除数据OfflineFirstNewsRepository.syncWith
9NewsResourceDao中的数据发生变化时,变化被发射到新闻资源数据流中(该流是一个 Flow)NewsResourceDao.getNewsResources
10OfflineFirstNewsRepository作为该流的中间操作符,将流入的PopulatedNewsResource(数据层内部使用的数据库模型)转换为供其他层消费的公开模型NewsResourceOfflineFirstNewsRepository.getNewsResources
11GetUserNewsResourcesUseCase将新闻资源列表与用户数据组合,发射出UserNewsResource列表GetUserNewsResourcesUseCase.invoke
12ForYouViewModel收到可保存的新闻资源时,将 feed 状态更新为SuccessForYouScreen随后使用状态中的新闻资源渲染屏幕搜索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依赖以下数据源:

名称底层技术用途
TopicsDaoRoom/SQLite与主题(Topics)相关的持久化关系型数据
NiaPreferencesDataSourceProto 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,通过SyncWorkerSynchronizer接口的实现)完成。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将一个来自NewsRepositoryNewsResource流(基于Flow)与一个来自UserDataRepositoryUserData流组合,生成UserNewsResource流。该流被多个 ViewModel 使用,用于在屏幕上展示带有书签状态的新闻资源。

仓库中领域层位于 core/domain,其下包含GetFollowableTopicsUseCaseGetRecentSearchQueriesUseCaseGetSearchContentsUseCase等用例。以 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 元素可以从流中读取最后已知的状态。

示例:展示已关注的主题

InterestsViewModeluiState暴露为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 的架构全貌。

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 18:01:12

2024最新Java环境搭建与配置实战指南

1. Java环境搭建全指南作为从业15年的Java老鸟&#xff0c;我见过太多新手卡在环境配置这一步。今天咱们不整虚的&#xff0c;直接上硬核实操手册。Java环境就像盖房子的地基&#xff0c;没搭好后面全是空中楼阁。最近帮团队新人排查问题&#xff0c;发现80%的报错都源于环境配…

作者头像 李华
网站建设 2026/9/13 18:00:09

OpenClaw插件生态:15款高效工具与开发实践

1. OpenClaw插件生态概述 OpenClaw作为一款新兴的多功能自动化工具&#xff0c;其强大之处在于开放的插件架构设计。2026年版本通过模块化设计实现了功能解耦&#xff0c;核心系统仅保留基础运行环境&#xff0c;90%以上的功能实现都交由插件完成。这种架构带来的直接优势是用户…

作者头像 李华
网站建设 2026/9/13 17:59:56

WorkBuddy创建专家全攻略:从智能体设计到自动化落地

1. WorkBuddy不像你想的那么简单——先搞懂“创建专家”到底在做什么我最早接触WorkBuddy&#xff0c;是看到有人拿它处理表格、盯群消息、定时发周报&#xff0c;以为又是一个套壳的聊天机器人。真正上手之后才发现&#xff0c;这个工具的底层逻辑完全不是“你问我答”&#x…

作者头像 李华