Now in Android `:feature:foryou:impl` 模块深度解析:依赖架构与 For You 个性化资讯流实现
Now in Android(NIA)是一个完全基于 Kotlin 与 Jetpack Compose 构建的开源 Android 应用,采用严格的分层模块化架构。本文以仓库中 feature/foryou/impl/README.md 文档的模块依赖图为主线,结合 :feature:foryou:impl 模块的真实源码、Gradle 构建脚本与测试用例,深度解析该模块在整体依赖体系中的位置、它与其他 core / feature 模块的协作关系,以及 For You 个性化资讯流(含主题引导 Onboarding、书签、深链跳转等能力)的完整实现路径。读完本文,你将掌握如何在 NIA 式分层模块化架构中组织一个功能模块的 api / impl 拆分、依赖边界控制与基于状态流的 Compose UI 实现。
一、模块定位:api / impl 分离的功能模块样板
在 Now in Android 的模块化体系中,每个业务功能(feature)都被拆分为 api 与 impl 两个独立模块,:feature:foryou 正是这一模式的典型代表:
:feature:foryou:api(见 feature/foryou/api/README.md):只暴露导航键等公共契约,依赖面极窄;:feature:foryou:impl(本文核心,见 feature/foryou/impl/README.md):承载 For You 屏幕的全部实现,通过api模块的契约对外提供服务。
从 feature/foryou/impl/build.gradle.kts 可以看到该模块声明了 nowinandroid.android.feature.impl 与 nowinandroid.android.library.compose 两个约定插件,即它是一个 Android 库 + Compose 库;同时启用了 Roborazzi 截图测试插件(libs.plugins.roborazzi),并在 testOptions.unitTests.isIncludeAndroidResources 下开启单元测试对 Android 资源的访问,为后续 Robolectric 单元测试与截图测试铺路。
模块内源码结构清晰(见 feature/foryou/impl 目录):
navigation/ForYouEntryProvider.kt:Navigation3 的 EntryProvider 入口;ForYouScreen.kt:Compose 屏幕实现与 Preview;ForYouViewModel.kt:Hilt ViewModel 与状态流;OnboardingUiState.kt:Onboarding 状态机;- 测试目录下另有
ForYouViewModelTest.kt、ForYouScreenTest.kt与截图测试。
二、模块依赖图:继承并解读原文档的架构骨架
原文档的核心是一张使用 Mermaid 绘制的模块依赖图。图中将模块按 :feature 与 :core 两个子图组织,并给出五种节点类型与两种连线语义的图例:
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :feature
direction TB
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
:feature:foryou:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
end
end
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
:core:common[common]:::jvm-library
:core:data[data]:::android-library
:core:database[database]:::android-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::jvm-library
:core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library
:core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library
end
:core:data -.-> :core:analytics
:core:data --> :core:common
:core:data --> :core:database
:core:data --> :core:datastore
:core:data --> :core:network
:core:data -.-> :core:notifications
:core:database --> :core:model
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:domain --> :core:data
:core:domain --> :core:model
:core:network --> :core:common
:core:network --> :core:model
:core:notifications -.-> :core:common
:core:notifications --> :core:model
:core:ui --> :core:analytics
:core:ui --> :core:designsystem
:core:ui --> :core:model
:feature:foryou:api --> :core:navigation
:feature:foryou:impl -.-> :core:designsystem
:feature:foryou:impl -.-> :core:domain
:feature:foryou:impl -.-> :core:notifications
:feature:foryou:impl -.-> :core:ui
:feature:foryou:impl -.-> :feature:foryou:api
:feature:foryou:impl -.-> :feature:topic:api
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
图中连线语义需重点区分:
| 符号 | 含义 | 在本图中的实例 |
|---|---|---|
A --> B |
编译期(implementation)依赖 | :core:data --> :core:database |
A -.-> B |
运行期(runtimeOnly / compileOnly 或测试期)依赖 | :core:data -.-> :core:analytics |
:feature:foryou:impl 的六条边全部为 -.-> 虚线:它依赖 :core:designsystem(UI 组件与主题)、:core:domain(用例层)、:core:notifications(通知与深链常量)、:core:ui(通用 UI 状态与工具)、:feature:foryou:api(自身契约)以及 :feature:topic:api(话题页导航)。这一“虚线”设计并非随机——它对应 Now in Android 约定的 implementation 声明方式,即 impl 模块可以直接持有这些模块的编译引用,但绝不向外传播(非 api 暴露),从而保证依赖边界只在 feature 内部生效。
三、从依赖图到构建脚本:依赖边界的源码级验证
依赖图中的每一条边都可以在 Gradle 脚本中精确找到对应。impl 模块的依赖声明(见 feature/foryou/impl/build.gradle.kts)与图完全一致:
dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.domain)
implementation(projects.core.notifications)
implementation(projects.feature.foryou.api)
implementation(projects.feature.topic.api)
implementation(libs.androidx.activity.compose)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
testImplementation(projects.core.testing)
testDemoImplementation(projects.core.screenshotTesting)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
}
而 api 模块(见 feature/foryou/api/build.gradle.kts)仅有一行核心依赖:
dependencies {
api(projects.core.navigation)
}
这正是依赖图中 :feature:foryou:api --> :core:navigation 一条实线的出处。api 模块只依赖 :core:navigation,并通过 api(...) 将该依赖传导给下游消费者(如 app 壳模块),确保导航键的类型契约可被跨模块引用。
值得注意的细节:impl 模块引入了 libs.accompanist.permissions(Accompanist 权限库)与 projects.core.notifications,前者对应源码中 Android 13+ 通知权限申请逻辑(见下文第四节),后者对应 DEEP_LINK_NEWS_RESOURCE_ID_KEY 深链常量——这两条依赖在 UI 代码层面都有真实落点,不是“为了架构而架构”的空依赖。
四、For You 屏幕实现:依赖如何转化为真实 UI 能力
依赖图中的 :core:ui、:core:designsystem 等模块,最终都汇入 ForYouScreen.kt 这个约 620 行的 Compose 屏幕实现中。
4.1 屏幕整体结构与懒加载
ForYouScreen 最外层是一个 LazyVerticalStaggeredGrid(瀑布流网格,StaggeredGridCells.Adaptive(300.dp) 自适应列数),内容由三部分构成:
onboarding(...):根据OnboardingUiState决定是否渲染主题选择引导;newsFeed(...):来自:core:ui的通用资讯流扩展函数,渲染NewsFeedUiState;- 一个
StaggeredGridItemSpan.FullLine的底部占位 item,通过WindowInsets.safeDrawing处理底部安全区。
屏幕上同时叠加了三层 UI:
AnimatedVisibility包裹的NiaOverlayLoadingWheel(来自:core:designsystem),在isSyncing || isFeedLoading || isOnboardingLoading时从顶部滑入;DraggableScrollbar(来自:core:designsystem的 scrollbar 组件),为瀑布流提供可拖拽滚动条;ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading },用于向系统上报“首帧可交互”时机(Time To Full Display),是androidx.activity:activity-compose提供的启动性能度量 API,对应 build.gradle.kts 中的libs.androidx.activity.compose依赖。
4.2 Onboarding 主题选择:水平网格与状态联动
当 OnboardingUiState.Shown 时,屏幕上会出现引导区:标题、副标题、LazyHorizontalGrid(GridCells.Fixed(3) 三行、heightIn(max = max(240.dp, 240.sp.toDp())) 的动态高度上限以适配字体缩放)、以及“Done”按钮(NiaButton,enabled = onboardingUiState.isDismissable)。
每个主题用 SingleTopicButton 呈现:Surface(selected = isSelected) + 主题图标(DynamicAsyncImage,占位图来自 api 模块的 feature_foryou_api_ic_icon_placeholder)+ NiaIconToggleButton(未选中显示 NiaIcons.Add,选中显示 NiaIcons.Check)。点击后通过 onTopicCheckedChanged(topicId, !isSelected) 回调驱动 ViewModel 更新数据层。
4.3 深链与通知权限:两个 LaunchedEffect 效应
@Composable
private fun DeepLinkEffect(
userNewsResource: UserNewsResource?,
onDeepLinkOpened: (String) -> Unit,
) {
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
LaunchedEffect(userNewsResource) {
if (userNewsResource == null) return@LaunchedEffect
if (!userNewsResource.hasBeenViewed) onDeepLinkOpened(userNewsResource.id)
launchCustomChromeTab(
context = context,
uri = Uri.parse(userNewsResource.url),
toolbarColor = backgroundColor,
)
}
}
DeepLinkEffect 监听 deepLinkedNewsResource(如点击通知后携带的资讯 ID),若该资讯尚未被阅读则先回调 onDeepLinkOpened,随后调用 :core:ui 的 launchCustomChromeTab 在 Custom Tab 中打开原文。
@OptIn(ExperimentalPermissionsApi::class)
private fun NotificationPermissionEffect() {
if (LocalInspectionMode.current) return
if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return
val notificationsPermissionState = rememberPermissionState(
android.Manifest.permission.POST_NOTIFICATIONS,
)
LaunchedEffect(notificationsPermissionState) {
val status = notificationsPermissionState.status
if (status is Denied && !status.shouldShowRationale) {
notificationsPermissionState.launchPermissionRequest()
}
}
}
NotificationPermissionEffect 仅在 Android 13(TIRAMISU)及以上、且非预览模式下自动请求 POST_NOTIFICATIONS 权限——这是 libs.accompanist.permissions 与 :core:notifications 依赖的实际用途。
五、ViewModel 状态流:四个 StateFlow 驱动屏幕
ForYouViewModel.kt 是一个 @HiltViewModel,通过构造函数注入 SyncManager、AnalyticsHelper、UserDataRepository、UserNewsResourceRepository 与 GetFollowableTopicsUseCase(对应依赖图中 :core:data / :core:domain / :core:analytics 链路)。它向外暴露四个 StateFlow,全部使用 SharingStarted.WhileSubscribed(5_000)——即订阅停止 5 秒后才停止上游收集:
| StateFlow | 数据来源 | 初始值 |
|---|---|---|
feedState |
userNewsResourceRepository.observeAllForFollowedTopics() 映射为 NewsFeedUiState.Success |
Loading |
onboardingUiState |
combine(shouldShowOnboarding, getFollowableTopics()) |
Loading |
isSyncing |
syncManager.isSyncing |
false |
deepLinkedNewsResource |
SavedStateHandle 中的 DEEP_LINK_NEWS_RESOURCE_ID_KEY 经 flatMapLatest 查询 |
null |
其中 deepLinkedNewsResource 的实现值得一提:它以 SavedStateHandle.getStateFlow 为起点,用 flatMapLatest 把“通知深链携带的资讯 ID”映射为仓库查询流(NewsResourceQuery(filterNewsIds = setOf(newsResourceId))),再 map { it.firstOrNull() } 取出第一条记录——这样即使配置变更(旋转屏幕)也能从 SavedState 恢复深链状态。
ViewModel 暴露的交互方法:
updateTopicSelection(topicId, isChecked)→userDataRepository.setTopicIdFollowedupdateNewsResourceSaved(newsResourceId, isChecked)→userDataRepository.setNewsResourceBookmarkedsetNewsResourceViewed(newsResourceId, viewed)→userDataRepository.setNewsResourceVieweddismissOnboarding()→userDataRepository.setShouldHideOnboarding(true)onDeepLinkOpened(newsResourceId)→ 清除 SavedStateHandle 中的深链 ID、上报news_deep_link_opened分析事件(type + 自定义Param,见文件底部扩展函数)、标记已读
六、Onboarding 状态机与导航集成
6.1 OnboardingUiState:四态密封接口
OnboardingUiState.kt 定义了 For You 引导区的完整状态机:
Loading:正在加载;LoadFailed:加载失败(当前 UI 对二者均不渲染内容);NotShown:无需引导(用户已完成引导或已有关注主题);Shown(topics: List<FollowableTopic>):展示主题列表,其isDismissable属性定义为topics.any { it.isFollowed }——只有至少关注一个主题时“Done”按钮才可点击。
6.2 导航:Navigation3 的 EntryProvider 模式
:feature:foryou:api 中的 ForYouNavKey.kt 是一个 @Serializable object ForYouNavKey : NavKey——借助 kotlinx.serialization 成为类型安全的导航键。
而 ForYouEntryProvider.kt 则把导航键与屏幕实现桥接起来:
fun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {
entry<ForYouNavKey> {
ForYouScreen(
onTopicClick = navigator::navigateToTopic,
)
}
}
点击资讯中的话题时,通过 navigator::navigateToTopic 跳转到 :feature:topic:api 提供的话题页——这正是依赖图中 :feature:foryou:impl -.-> :feature:topic:api 这条虚线的功能落点。api / impl 分离在这里得到体现:app 壳模块只依赖 api 模块的类型(ForYouNavKey),而实现细节被完全封装在 impl 模块内部。
七、测试验证:单元测试、仪器测试与截图测试
依赖图展示的架构并非纸上谈兵,:feature:foryou:impl 配备了完整的三层测试栈:
7.1 ViewModel 单元测试
ForYouViewModelTest.kt 使用 Robolectric + MainDispatcherRule + core:testing 提供的 TestUserDataRepository、TestTopicsRepository、TestNewsRepository、TestSyncManager、TestAnalyticsHelper 等测试替身,验证了十余个状态场景,例如:
stateIsInitiallyLoading:初始状态必须是OnboardingUiState.Loading与NewsFeedUiState.Loading;onboardingIsShownWhenNewsResourcesAreLoading:主题加载完成、未关注任何主题时进入Shown态;onboardingIsNotShownAfterUserDismissesOnboarding:调用dismissOnboarding()后进入NotShown态,且资讯流随仓库数据更新;topicSelectionUpdatesAfterSelectingTopic:updateTopicSelection("1", true)后FollowableTopic.isFollowed与资讯流同步变化;deepLinkedNewsResourceIsFetchedAndResetAfterViewing:写入DEEP_LINK_NEWS_RESOURCE_ID_KEY后能取到对应资讯,onDeepLinkOpened后清空,并断言分析事件news_deep_link_opened被记录。
7.2 仪器测试与截图测试
ForYouScreenTest.kt(见 feature/foryou/impl/src/androidTest):Compose UI 仪器测试;ForYouScreenScreenshotTests.kt:基于 Roborazzi 的截图测试,针对加载中、已加载资讯流、主题选择等状态,在手机(phone)、平板(tablet)、折叠屏(foldable)、深色主题等组合下生成截图(见 feature/foryou/impl/src/test/screenshots 目录),并与基线比对,防止 UI 意外回归。
上述两张截图分别对应 OnboardingUiState.Loading / NewsFeedUiState.Loading(顶部加载条)与 OnboardingUiState.Shown(三行主题水平网格)两种 UI 状态,与源码中 AnimatedVisibility 加载动画和 LazyHorizontalGrid 主题网格一一对应。
八、总结:一张依赖图背后的模块化设计原则
从 feature/foryou/impl/README.md 这张 Mermaid 依赖图出发,可以提炼出 Now in Android 在 feature 模块设计上的几条核心原则:
- api / impl 双层拆分:
api模块只暴露导航键等零实现契约,依赖面收敛到:core:navigation;impl模块持有全部实现,即使 impl 被整体替换,也不影响 app 壳与导航层。 - feature 只依赖 core,feature 之间通过 api 交互:
:feature:foryou:impl通过:feature:topic:api的导航扩展完成跨 feature 跳转,而不是直接依赖 topic 的实现模块。 - core 层内部也严格分层:
data依赖database / datastore / network / common,domain依赖data / model,ui依赖designsystem / model / analytics——每层职责单一,虚线边表示implementation级的非传播依赖。 - 一切架构都有测试兜底:状态机有 ViewModel 单元测试,UI 有仪器测试与 Roborazzi 截图测试,深链与分析事件也有断言覆盖。
对于想要在自有项目中复刻这种架构的开发者,最直接的方式是以 :feature:foryou 为模板:先定义 api 模块的 NavKey,再用 impl 模块实现 EntryProvider、屏幕与 ViewModel,最后用 implementation 精确声明依赖、以 testImplementation 引入 core:testing 的测试替身栈——这样既能获得类型安全的导航与清晰的分层边界,也能让每个功能模块独立构建、独立测试。
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 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python440
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2.01 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python49569
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,显著的提高效率,又不失灵活~Java34651

