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.initialize2ForYouViewModel调用GetUserNewsResourcesUseCase获取带有书签/已保存状态的新闻资源流。直到用户仓库user repository与新闻仓库news repository都发出数据该流中才会有数据。等待期间feed 状态被置为Loading搜索NewsFeedUiState.Loading的使用处3用户数据仓库从基于 Proto DataStore 的本地数据源获取UserData对象流NiaPreferencesDataSource.userData4WorkManager 执行同步任务调用OfflineFirstNewsRepository开始与远程数据源同步数据SyncWorker.doWork5OfflineFirstNewsRepository调用RetrofitNiaNetwork使用 Retrofit 执行实际的 API 请求OfflineFirstNewsRepository.syncWith6RetrofitNiaNetwork调用远程服务器上的 REST APIRetrofitNiaNetwork.getNewsResources7RetrofitNiaNetwork收到远程服务器的网络响应RetrofitNiaNetwork.getNewsResources8OfflineFirstNewsRepository通过NewsResourceDao同步远程数据——在本地 Room 数据库中插入、更新或删除数据OfflineFirstNewsRepository.syncWith9当NewsResourceDao中的数据发生变化时变化被发射到新闻资源数据流中该流是一个 FlowNewsResourceDao.getNewsResources10OfflineFirstNewsRepository作为该流的中间操作符将流入的PopulatedNewsResource数据层内部使用的数据库模型转换为供其他层消费的公开模型NewsResourceOfflineFirstNewsRepository.getNewsResources11GetUserNewsResourcesUseCase将新闻资源列表与用户数据组合发射出UserNewsResource列表GetUserNewsResourcesUseCase.invoke12当ForYouViewModel收到可保存的新闻资源时将 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流即可获得ListTopic。每当主题列表发生变化例如新增了一个主题更新后的ListTopic会被发射到流中。对应的真实实现见 OfflineFirstTopicsRepository.ktoverride fun getTopics(): FlowListTopic topicDao.getTopicEntities() .map { it.map(TopicEntity::asExternalModel) }注意其中的数据转换DAO 返回的是数据库实体TopicEntity仓库通过asExternalModel()将其映射为领域/UI 层消费的Topic模型——这正是第 10 步描述的数据层内部模型与公开模型隔离的体现。写入数据写入数据时仓库提供的是挂起函数suspend functions。是否让这些函数在合适的协程作用域scope内执行由调用方负责。示例关注一个主题只需调用UserDataRepository.toggleFollowedTopicId传入用户想要关注的主题 ID并设置followedtrue表示关注传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 按指数退避重试 } } }可以观察到几个关键设计主题仓库与新闻仓库的同步并行执行awaitAllasync同步失败时返回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将一个来自NewsRepository的NewsResource流基于Flow与一个来自UserDataRepository的UserData流组合生成UserNewsResource流。该流被多个 ViewModel 使用用于在屏幕上展示带有书签状态的新闻资源。仓库中领域层位于 core/domain其下包含GetFollowableTopicsUseCase、GetRecentSearchQueriesUseCase、GetSearchContentsUseCase等用例。以 GetFollowableTopicsUseCase.kt 为例可以看到组合两个流的标准写法operator fun invoke(sortBy: TopicSortField NONE): FlowListFollowableTopic 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 layerUI 层由以下部分组成使用 Jetpack Compose 构建的 UI 元素Android ViewModels。ViewModel 从用例和仓库接收数据流并将它们转换为 UI 状态。UI 元素反映这一状态并给用户提供交互方式这些交互作为事件传递给 ViewModel 处理。建模 UI 状态UI 状态使用接口与不可变数据类建模为密封层级sealed hierarchy。状态对象只会在数据流转换过程中被发射。这种做法保证了UI 状态始终代表底层应用数据——应用数据才是单一事实来源UI 元素能够处理所有可能的状态。示例For You 屏幕的新闻 feedFor 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暴露为StateFlowInterestsUiState。这个热流由GetFollowableTopicsUseCase提供的ListFollowableTopic冷流创建每当新的列表被发射就转换为一个InterestsUiState.Interests状态暴露给 UI。在 ForYouViewModel.kt 中可以看到stateIn的典型用法对于深链新闻资源deep link观察使用SavedStateHandle.getStateFlowflatMapLatest组合并用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),仅供参考