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

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

2026-09-12 15:27:51作者:宣利权Counsellor

本文是 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 官方架构指南的组织方式。

Now in Android 整体架构图:数据层在最底部,领域层居中,UI 层在最顶部

[!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)向用户展示这些新闻。

下面的时序图展示了这一过程中发生的事件,以及数据如何在相关对象之间流动:

For You 屏幕展示新闻资源的数据流时序图:从 Sync.initialize 启动同步,到 ForYouScreen 渲染新闻列表

以下是每一步的详细说明。在仓库中定位对应代码最便捷的方式,是把项目导入 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 状态更新为 SuccessForYouScreen 随后使用状态中的新闻资源渲染屏幕 搜索 NewsFeedUiState.Success 的实例

这 12 步完整展示了"事件向下流、数据向上流"的闭环:用户启动应用(事件)→ 触发同步(向下流向数据层)→ 数据经 Room、仓库、用例逐层向上转换 → 最终以 UI 状态的形式渲染到屏幕。

数据层(Data layer)

数据层被实现为应用数据与业务逻辑的离线优先(offline-first)来源,是整个应用中所有数据的单一事实来源(source of truth)

Now in Android 数据层架构图:仓库依赖多个数据源,对外暴露数据流

每个仓库(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,通过 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.syncWithOfflineFirstNewsRepository.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 层由以下部分组成:

ViewModel 从用例和仓库接收数据流,并将它们转换为 UI 状态。UI 元素反映这一状态,并给用户提供交互方式;这些交互作为事件传递给 ViewModel 处理。

Now in Android UI 层架构图: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/foryoucore/datacore/domain 三个目录中对照本文提到的类逐一阅读,即可完整掌握 Now in Android 的架构全貌。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
34
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.16 K
2.79 K
docsdocs
暂无描述
Markdown
904
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
936
1.86 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
863
1.36 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
535
606
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.38 K
1.47 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
4.1 K
1.03 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
549
400
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.09 K
540