从零到一:用 Now in Android 架构打造一款 NBA 应用
本文以开源项目 HoopsNow 为例,深度拆解 Google 官方推荐的 Now in Android (NIA) 架构在真实项目中的落地实践。涵盖多模块拆分、Convention Plugins、Feature API/Impl 分层、离线优先数据层、Navigation 3 导航以及 Jetpack Compose + MVVM 状态管理等核心主题。
目录
- 为什么选择 NIA 架构
- 项目概览
- 模块化设计:从单体到多模块
- Convention Plugins:告别重复的构建配置
- Feature API/Impl 分层模式
- 数据层:离线优先架构
- Navigation 3:类型安全的导航系统
- ViewModel + UiState:单向数据流实践
- Hilt 依赖注入:把一切粘合在一起
- 总结与收获
为什么选择 NIA 架构
Google 在 2022 年推出了 Now in Android 示例项目,它不是一个简单的 Demo,而是 Google 对「现代 Android 应用该怎么写」这个问题给出的官方答案。
NIA 架构的核心理念:
- 模块化 — 按功能拆分模块,提升构建速度和团队协作效率
- 关注点分离 — UI、数据、业务逻辑各司其职
- 离线优先 — 本地数据库作为唯一数据源(Single Source of Truth)
- 单向数据流 (UDF) — 状态向下流动,事件向上流动
- Convention Plugins — 统一构建配置,消除模块间的 build.gradle 重复
但 NIA 官方项目本身过于庞大(60+ 模块),对于想要学习的开发者来说,入门门槛不低。因此,我做了 HoopsNow — 一个结构清晰、规模适中的 NBA 数据应用,作为 NIA 架构的教学实践。
项目概览
HoopsNow 是一款 NBA 数据应用,功能包括:
| 功能 | 说明 |
|---|---|
| 比赛 | 查看每日 NBA 比赛比分与赛程 |
| 球队 | 浏览 30 支球队信息(东/西部分区) |
| 球员 | 搜索球员、查看球员详情 |
| 收藏 | 收藏喜爱的球队和球员 |
技术栈一览:
| 类别 | 技术 |
|---|---|
| 语言 | Kotlin |
| UI | Jetpack Compose + Material 3 |
| 导航 | Navigation 3 |
| 依赖注入 | Hilt |
| 数据库 | Room |
| 偏好存储 | DataStore |
| 网络 | Retrofit + OkHttp + Kotlin Serialization |
| 异步 | Coroutines + Flow |
| 构建 | Convention Plugins + Typesafe Project Accessors |
模块化设计:从单体到多模块
为什么要多模块?
单模块项目在初期很方便,但随着代码量增长,你会遇到:
- 构建时间膨胀 — 改一行代码,整个项目重新编译
- 依赖混乱 — 任何类都可以互相引用,耦合度爆炸
- 团队协作冲突 — 多人修改同一模块,频繁冲突
- 代码边界模糊 — 业务逻辑和 UI 混在一起
多模块化解决了这些问题。Gradle 可以并行编译独立模块,模块之间有明确的依赖关系,改动一个模块不会影响其他模块的编译。
HoopsNow 模块结构
1 | HoopsNow/ |
总计 19 个模块,结构清晰:
- app — 只负责”粘合”,把各功能模块组装起来
- feature — 每个业务功能独立成模块
- core — 可复用的基础设施
模块依赖关系
1 | app |
关键原则:feature 模块之间不直接依赖 impl,只依赖 api。 这保证了模块间的松耦合。
Convention Plugins:告别重复的构建配置
痛点
多模块项目有一个常见问题:每个模块的 build.gradle.kts 都要写一堆重复配置 — compileSdk、minSdk、jvmTarget、Compose 配置、Hilt 配置……
改一个版本号,要改 19 个文件?这不可接受。
解决方案:Convention Plugins
Convention Plugins 是 Gradle 的一个强大特性 — 你可以把公共的构建逻辑封装成插件,模块只需一行代码就能应用。
HoopsNow 定义了 8 个 Convention Plugin:
| 插件 ID | 作用 |
|---|---|
hoopsnow.android.application |
Android Application 基础配置 |
hoopsnow.android.application.compose |
Application + Compose 支持 |
hoopsnow.android.library |
Android Library 基础配置 |
hoopsnow.android.library.compose |
Library + Compose 支持 |
hoopsnow.android.feature |
Feature 模块一站式配置 |
hoopsnow.android.hilt |
Hilt 依赖注入配置 |
hoopsnow.android.room |
Room 数据库配置 |
hoopsnow.jvm.library |
纯 JVM 库(无 Android 依赖) |
示例:AndroidFeatureConventionPlugin
这是最能体现 Convention Plugin 威力的一个:
1 | class AndroidFeatureConventionPlugin : Plugin<Project> { |
一个插件 = Library 配置 + Compose 配置 + Hilt 配置 + 公共依赖。
使用后的 build.gradle.kts
看看 Feature 模块的 build.gradle.kts 变得多简洁:
1 | // feature/games/impl/build.gradle.kts |
注意 projects.feature.games.api — 这是 Typesafe Project Accessors,在 settings.gradle.kts 中启用:
1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") |
相比字符串 project(":feature:games:api"),类型安全的访问器能在编译期发现拼写错误。
公共配置:ProjectExtensions.kt
所有 Android 模块共享的 Kotlin/Android 配置也被提取了出来:
1 | internal fun CommonExtension<*, *, *, *, *, *>.configureKotlinAndroid(project: Project) { |
版本号全部来自 libs.versions.toml,改一处,全局生效。
Feature API/Impl 分层模式
这是 HoopsNow 中最有特色的架构决策之一。
为什么要分 api 和 impl?
假设你有 feature:games 和 feature:teams 两个模块。在比赛详情页面,用户点击球队名称要跳转到球队详情页面。这意味着 feature:games:impl 需要知道如何导航到 feature:teams 的页面。
如果直接依赖 impl:
1 | feature:games:impl → feature:teams:impl ❌ |
问题来了:
teams:impl的任何改动都会触发games:impl重新编译- 两个 impl 互相依赖会造成循环依赖
- impl 的内部实现(ViewModel、Screen)被暴露
用 api/impl 分离:
1 | feature:games:impl → feature:teams:api ✅ |
api 模块只包含什么?
仅导航契约 — NavKey 定义:
1 | // feature/games/api/.../GamesNavKeys.kt |
就这么简单。api 模块极度轻量:
- 没有 Compose 依赖
- 没有 ViewModel
- 没有业务逻辑
- 只有 Kotlin Serialization 和 Navigation 3 Runtime
对应的 build.gradle.kts:
1 | // feature/games/api/build.gradle.kts |
impl 模块包含什么?
所有的具体实现:
1 | feature/games/impl/ |
这种模式的好处
- 编译隔离 —
games:impl的改动不会影响依赖games:api的其他模块 - 禁止循环依赖 — 模块间只能通过轻量的 api 通信
- 构建加速 — api 模块极少变化,大部分编译可以增量跳过
- 封装性 — impl 中的 ViewModel、Screen 等实现细节对外不可见
数据层:离线优先架构
三层模型转换
NIA 架构中,数据经历三次模型转换:
1 | Network Model → Domain Model → Entity (Database) |
为什么需要三套模型?
- NetworkGame — 匹配 API JSON 结构,包含
@SerialName注解 - Game — 纯领域模型,UI 层直接使用,不依赖任何框架
- GameEntity — Room 数据库实体,扁平化结构方便存储
1 | // 领域模型 — 纯 Kotlin,无框架依赖 |
1 | // 数据库实体 — 扁平化,嵌套对象拆为独立字段 |
模型之间通过扩展函数转换:
1 | // Network → Domain |
Repository 模式
Repository 接口定义在 core:data 模块中:
1 | interface GamesRepository { |
关键设计:所有查询方法返回 Flow,而不是 suspend 函数。 这意味着数据是响应式的 — 数据库有更新时,UI 会自动刷新。
离线优先实现
1 | internal class OfflineFirstGamesRepository constructor( |
数据流向:
1 | 用户请求 → 订阅 Room Flow → Room 返回缓存数据 → UI 立即展示 |
这就是 Single Source of Truth 原则:UI 永远只从数据库读数据,网络数据先写入数据库再由 Flow 推送。
DAO 层
1 |
|
使用 @Upsert 替代 @Insert(onConflict = REPLACE),这是 Room 的最佳实践 — 存在则更新,不存在则插入。
Navigation 3:类型安全的导航系统
为什么不用 Navigation Compose?
Navigation 3 是 Google 最新的导航库(2025 年发布),相比 Navigation Compose 有几个核心优势:
- 类型安全 — 路由参数通过数据类传递,而非 String
- 更灵活的 BackStack 管理 — 直接操作 BackStack,无需复杂的
popUpTo配置 - 与 ViewModel 更好集成 — 内置
ViewModelStoreNavEntryDecorator
NavKey:导航的基石
每个可导航的目的地都定义为一个 NavKey:
1 | // 列表页 — 无参数,用 object |
@Serializable 保证了导航参数可以在进程死亡后恢复。
双层导航架构
HoopsNow 采用双层导航设计:
1 | TopLevelStack(底部导航栏) |
- TopLevelStack — 管理底部导航栏的 Tab 切换
- SubStack — 每个 Tab 有自己的子导航栈
1 | class NavigationState( |
Navigator:导航逻辑
1 | class Navigator(val state: NavigationState) { |
这种设计的优点是:
- 每个 Tab 的导航栈独立保存,切换 Tab 不会丢失状态
- 双击当前 Tab 可以回到顶部(微信同款交互)
- 返回逻辑清晰,不需要
popUpTo这种声明式配置
ViewModel + UiState:单向数据流实践
UiState 密封接口
每个页面定义一个密封接口来表示所有可能的 UI 状态:
1 | sealed interface GamesUiState { |
为什么用 sealed interface 而不是 sealed class?
sealed interface允许多继承data object比object更适合作为状态(有正确的toString())- 编译器会在
when表达式中检查是否覆盖了所有分支
ViewModel 实现
1 |
|
数据流向图:
1 | ┌─────────────────────────────────────────────────────┐ |
Screen 中的状态消费
1 |
|
关键细节:
collectAsStateWithLifecycle()— 生命周期感知的状态收集,Activity 进入后台时自动停止收集,避免浪费资源key = { it.id }— 为 LazyColumn 提供稳定的 key,避免不必要的重组hiltViewModel()— Hilt 自动创建和管理 ViewModel 实例
SharingStarted.WhileSubscribed(5_000) 的意义
这是 NIA 推荐的 StateFlow 共享策略:
- 有订阅者时开始收集上游 Flow
- 所有订阅者消失后,等待 5 秒再停止收集
- 5 秒内如果有新订阅者(比如屏幕旋转),直接复用已有数据
为什么是 5 秒?因为屏幕旋转通常在几秒内完成,5 秒足够覆盖配置变化的窗口期。
Hilt 依赖注入:把一切粘合在一起
绑定 Repository
1 |
|
设计要点:
internal abstract class— 模块内部可见,外部只能看到接口@Binds— 比@Provides更高效,Hilt 在编译期生成绑定代码@Singleton— 全局单例,所有 ViewModel 共享同一个 Repository 实例
ViewModel 注入
1 |
|
Hilt 看到 GamesRepository 参数,会通过 DataModule 的绑定找到 OfflineFirstGamesRepository 并注入。ViewModel 完全不知道具体实现是什么。
这就是依赖倒置原则 (DIP) 的实践 — 高层模块依赖抽象(接口),不依赖具体实现。
总结与收获
架构决策速查表
| 决策 | 选择 | 理由 |
|---|---|---|
| 模块化策略 | feature(api/impl) + core | 编译隔离、松耦合 |
| 构建配置 | Convention Plugins | 消除 build.gradle 重复 |
| 导航方案 | Navigation 3 | 类型安全、灵活的 BackStack |
| 状态管理 | StateFlow + sealed interface | 编译期穷举检查、响应式 |
| 数据策略 | 离线优先 (Room + Retrofit) | 用户体验好、网络容错 |
| 依赖注入 | Hilt | Android 官方推荐 |
| UI 框架 | Jetpack Compose + Material 3 | 声明式 UI、现代设计 |
| 模块依赖引用 | Typesafe Project Accessors | 编译期检查模块路径 |
NIA 架构的适用场景
适合:
- 中大型项目(5+ 功能模块)
- 多人协作团队
- 需要离线支持的应用
- 长期维护的产品
过度设计的场景:
- 简单的工具类 App
- 只有 1-2 个页面的 Demo
- 一次性项目
从 NIA 学到的核心原则
- 模块边界即架构边界 — 好的模块划分自然会带来好的架构
- Convention over Configuration — 约定优于配置,Plugin 比文档更可靠
- Single Source of Truth — 一个数据只有一个权威来源(数据库)
- 响应式 > 命令式 — Flow 比手动调用
refresh()更优雅 - 编译期 > 运行时 — 类型安全的导航、sealed interface 的穷举检查
项目地址
项目已开源,欢迎 Star 和 PR:
GitHub: https://github.com/laibinzhi/hoopsnow
如果这篇文章对你有帮助,欢迎分享给更多 Android 开发者。NIA 架构不是银弹,但它是当前 Android 开发的最佳实践集合,值得每一个 Android 开发者学习和借鉴。