首页
/ Now in Android `:feature:foryou:impl` 模块深度解析:依赖架构与 For You 个性化资讯流实现

Now in Android `:feature:foryou:impl` 模块深度解析:依赖架构与 For You 个性化资讯流实现

2026-09-12 11:32:14作者:裘晴惠Vivianne

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)都被拆分为 apiimpl 两个独立模块,:feature:foryou 正是这一模式的典型代表:

feature/foryou/impl/build.gradle.kts 可以看到该模块声明了 nowinandroid.android.feature.implnowinandroid.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.ktForYouScreenTest.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) 自适应列数),内容由三部分构成:

  1. onboarding(...):根据 OnboardingUiState 决定是否渲染主题选择引导;
  2. newsFeed(...):来自 :core:ui 的通用资讯流扩展函数,渲染 NewsFeedUiState
  3. 一个 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 时,屏幕上会出现引导区:标题、副标题、LazyHorizontalGridGridCells.Fixed(3) 三行、heightIn(max = max(240.dp, 240.sp.toDp())) 的动态高度上限以适配字体缩放)、以及“Done”按钮(NiaButtonenabled = 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:uilaunchCustomChromeTab 在 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,通过构造函数注入 SyncManagerAnalyticsHelperUserDataRepositoryUserNewsResourceRepositoryGetFollowableTopicsUseCase(对应依赖图中 :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_KEYflatMapLatest 查询 null

其中 deepLinkedNewsResource 的实现值得一提:它以 SavedStateHandle.getStateFlow 为起点,用 flatMapLatest 把“通知深链携带的资讯 ID”映射为仓库查询流(NewsResourceQuery(filterNewsIds = setOf(newsResourceId))),再 map { it.firstOrNull() } 取出第一条记录——这样即使配置变更(旋转屏幕)也能从 SavedState 恢复深链状态。

ViewModel 暴露的交互方法:

  • updateTopicSelection(topicId, isChecked)userDataRepository.setTopicIdFollowed
  • updateNewsResourceSaved(newsResourceId, isChecked)userDataRepository.setNewsResourceBookmarked
  • setNewsResourceViewed(newsResourceId, viewed)userDataRepository.setNewsResourceViewed
  • dismissOnboarding()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 提供的 TestUserDataRepositoryTestTopicsRepositoryTestNewsRepositoryTestSyncManagerTestAnalyticsHelper 等测试替身,验证了十余个状态场景,例如:

  • stateIsInitiallyLoading:初始状态必须是 OnboardingUiState.LoadingNewsFeedUiState.Loading
  • onboardingIsShownWhenNewsResourcesAreLoading:主题加载完成、未关注任何主题时进入 Shown 态;
  • onboardingIsNotShownAfterUserDismissesOnboarding:调用 dismissOnboarding() 后进入 NotShown 态,且资讯流随仓库数据更新;
  • topicSelectionUpdatesAfterSelectingTopicupdateTopicSelection("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 意外回归。

For You 屏幕加载中的真实截屏(手机竖屏)

For You 屏幕主题选择引导的真实截屏(手机竖屏)

上述两张截图分别对应 OnboardingUiState.Loading / NewsFeedUiState.Loading(顶部加载条)与 OnboardingUiState.Shown(三行主题水平网格)两种 UI 状态,与源码中 AnimatedVisibility 加载动画和 LazyHorizontalGrid 主题网格一一对应。

八、总结:一张依赖图背后的模块化设计原则

feature/foryou/impl/README.md 这张 Mermaid 依赖图出发,可以提炼出 Now in Android 在 feature 模块设计上的几条核心原则:

  1. api / impl 双层拆分api 模块只暴露导航键等零实现契约,依赖面收敛到 :core:navigationimpl 模块持有全部实现,即使 impl 被整体替换,也不影响 app 壳与导航层。
  2. feature 只依赖 core,feature 之间通过 api 交互:feature:foryou:impl 通过 :feature:topic:api 的导航扩展完成跨 feature 跳转,而不是直接依赖 topic 的实现模块。
  3. core 层内部也严格分层data 依赖 database / datastore / network / commondomain 依赖 data / modelui 依赖 designsystem / model / analytics——每层职责单一,虚线边表示 implementation 级的非传播依赖。
  4. 一切架构都有测试兜底:状态机有 ViewModel 单元测试,UI 有仪器测试与 Roborazzi 截图测试,深链与分析事件也有断言覆盖。

对于想要在自有项目中复刻这种架构的开发者,最直接的方式是以 :feature:foryou 为模板:先定义 api 模块的 NavKey,再用 impl 模块实现 EntryProvider、屏幕与 ViewModel,最后用 implementation 精确声明依赖、以 testImplementation 引入 core:testing 的测试替身栈——这样既能获得类型安全的导航与清晰的分层边界,也能让每个功能模块独立构建、独立测试。

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