幕 IV · 开工CH 06进度 06/06

从零搭一个项目

前五章拆完了地基、比完了流派。这一章把所有决定落成一个真的能跑的骨架:模块怎么切、依赖往哪个方向流、那个所有人都在抄却很少有人讲清楚的 build-logic 到底在解决什么问题,以及一份版本号全部取自三个真实项目实测值libs.versions.toml。最后是这本书真正的交付物 —— 一张你能拿去用的选型决策表。

:app / :feature / :core依赖三铁律convention plugin版本目录选型决策表

模块化:它买的不是「整洁」,是编译时间

先把动机说清楚,因为「模块化」这个词被讲得太玄了。

◆ 判词 · 分模块的第一收益是增量编译

单模块项目里,你改一行代码,Gradle 得重新检查整个项目。分了模块之后, 你改 :feature:news 里的一行,只有 :feature:news 和依赖它的 :app 需要重编译 —— 另外十几个模块连碰都不碰。

这就是为什么依赖方向如此要命:一旦 :core:model 反过来依赖了某个 feature, 你改那个 feature,整棵树都得重编译,模块化的收益瞬间归零。

「代码整洁」只是副产品。省下来的那几十秒,才是你每天都在赚的东西。

三条铁律

NIA 的模块图遵守三条规则,它们不是品味问题,每条都对应一种具体的事故:

依赖只向下 :app:feature:*:core:*永远不反向。 违反 = 增量编译失效
feature 之间互不相识 :feature:news 不知道 :feature:bookmarks 存在。共同的东西下沉到 :core:* 违反 = 两个 feature 半年后长成一个
循环依赖 = 硬错误 Gradle 直接拒绝配置项目。这是唯一一种「连编译都到不了」的错。 违反 = 构建根本起不来

下面这个 Demo 里,初始状态就是 NIA 的那张图。你可以往里加边 —— 试试 「制造一个循环」,或者手动加一条 :feature:news:feature:bookmarks, 看它会怎么骂你:

⚠ 「那两个 feature 之间真的要跳转呢?」

这是最常见的反驳。新闻页要能跳到收藏页 —— 不依赖它,怎么跳?

答案在第 3 章。Nav3 里「跳转」只是往返回栈里塞一个 key: backStack.add(BookmarksRoute)。而 BookmarksRoute 是一个 @Serializable 的 data class —— 它可以住在一个薄薄的 :core:navigation 模块里,谁都能依赖。

:feature:news 只需要知道那个 key 的类型,完全不需要知道收藏页长什么样、由谁实现。 这就是「互不相识」在技术上如何可能。

build-logic:消灭那重复了 20 遍的 40 行

分了模块之后,你会立刻撞上一个新问题。每个 feature 模块的 build.gradle.kts 都长这样:

// :feature:news/build.gradle.kts —— 然后 :feature:bookmarks 里一模一样
// 再然后 :feature:settings 里还是一模一样……
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose")
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}
android {
    compileSdk = 36
    defaultConfig { minSdk = 24 }
    compileOptions { /* …… */ }
    buildFeatures { compose = true }
}
dependencies {
    implementation(project(":core:data"))
    implementation(project(":core:designsystem"))
    implementation(libs.androidx.lifecycle.runtime.compose)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    // …… 再来二十行
}

复制粘贴二十遍。然后有一天你要升 compileSdk —— 改二十个文件,漏一个。

build-logic 是这个问题的解:把重复的那坨配置,写成一个你自己的 Gradle 插件。

// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        // 把上面那四十行,在这里写一次
        pluginManager.apply("myapp.android.library")
        pluginManager.apply("myapp.android.compose")
        pluginManager.apply("myapp.hilt")

        dependencies {
            add("implementation", project(":core:data"))
            add("implementation", project(":core:designsystem"))
            add("implementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get())
        }
    }
}

于是每个 feature 模块的构建脚本,缩成了这四行:

// :feature:news/build.gradle.kts —— 全文如下
plugins {
    alias(libs.plugins.myapp.android.feature)
}
android { namespace = "com.example.feature.news" }
▣ 承重 · 这就是 NIA 那套 build-logic 的全部意义

很多人抄 NIA 的 build-logic 目录时把它当成某种高深的仪式。它不是。 它就是「把复制粘贴二十遍的东西抽成一个函数」 —— 只不过这个「函数」恰好是个 Gradle 插件。

判断你要不要它,标准很简单:模块数超过五六个,就值得。三五个模块的项目,直接写 build.gradle.kts 更省事。

一份可以直接抄的版本目录

下面这份 gradle/libs.versions.toml,版本号全部取自本书那三个真实项目 2026 年 7 月的实测值 —— 不是我编的,是从它们仓库里的版本目录抄出来的。

[versions]
# —— 构建工具链(取自 Now in Android) ——
agp = "9.0.0"
kotlin = "2.3.0"                  # Mihon 已经上到 2.4.0 了,也很稳

# —— Compose(取自 Mihon,是目前最新的 BOM) ——
composeBom = "2026.06.01"

# —— 导航:Navigation 3(2025-11 转正,当前稳定版) ——
navigation3 = "1.1.3"

# —— Jetpack ——
lifecycle = "2.11.0"
room = "2.8.3"                    # NIA 在用
hilt = "2.59"                     # NIA 在用

# —— Kotlin 生态 ——
coroutines = "1.11.0"
serialization = "1.11.0"

# —— 网络与图片 ——
okhttp = "5.4.0"                  # Mihon 在用(注意:5.x 了)
coil = "3.5.0"                    # Coil 3 是多平台的

[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui      = { module = "androidx.navigation3:navigation3-ui",      version.ref = "navigation3" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose   = { module = "androidx.lifecycle:lifecycle-runtime-compose",   version.ref = "lifecycle" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
hilt-android  = { module = "com.google.dagger:hilt-android",          version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android      = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose      = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
# ↑ 注意:Compose 编译器现在跟着 kotlin 的版本号走(第 2 章)
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
⚠ 抄之前请核对一次

这些数字是 2026 年 7 月的快照。Android 生态大约每季度动一次,抄的时候顺手去 developer.android.com 对一眼最新版,尤其是 navigation3composeBom

项目骨架长什么样

MyApp/
├─ build-logic/                     ← convention plugin 住这儿(模块 > 5 个才需要)
│   └─ convention/
│       └─ AndroidFeatureConventionPlugin.kt
├─ gradle/
│   └─ libs.versions.toml           ← 上面那份
├─ app/                             ← 只是个壳:拼装 + entryProvider + Application
├─ feature/
│   ├─ news/                        ← 每个 feature 一个模块,彼此不相识
│   ├─ bookmarks/
│   └─ settings/
└─ core/
    ├─ navigation/                  ← 只放 @Serializable 的路由 key。谁都能依赖
    ├─ designsystem/                ← 主题、颜色、可复用组件
    ├─ data/                        ← Repository 实现。feature 只跟它打交道
    ├─ database/                    ← Room。只有 :core:data 能碰它
    ├─ network/                     ← Retrofit / Ktor。只有 :core:data 能碰它
    └─ model/                       ← 纯 Kotlin data class,不依赖任何人

注意 :core:database:core:network 的位置:只有 :core:data 能依赖它们。 这样一来,「数据是从库里来的还是从网上来的」这个决定,被彻底封死在 :core:data 内部 —— 这正是第 5 章那个 SSOT 结构在模块层面的样子。上面的 Demo 里,你把 :app 直连到 :core:database,它就会警告你,原因就在这儿。

最后一张表:你该选哪一档

这是整本书的交付物。

你在做的是 导航 状态 DI 数据
普通商业 Android app
绝大多数人在这一栏
Navigation 3 ViewModel + StateFlow Hilt Room + Retrofit
要同时出 iOS Nav3(它是多平台的) Composable Presenter
或多平台 ViewModel
Metro / Koin Room(KMP)或 SQLDelight + Ktor
插件化 / 运行时动态加载 看框架 ScreenModel 之类 服务定位器
编译期 DI 在这儿无效
SQLDelight + OkHttp
屏幕状态极复杂
消息 / 邮件类
Nav3 或 Circuit 自带 Circuit 任意 任意
老项目,跑得好好的 Nav2 不急着换
但新 feature 用 Nav3
别动 别动 别动
◆ 判词 · 默认那一行,占了这个表 80% 的重量

第一行不是「保守」,是正确。生态、招人、文档、Stack Overflow、以及 AI 的训练数据, 全都堆在那一行上。偏离它需要一个你能说出口的理由 —— 而这本书的全部努力, 就是让你在需要偏离时,说得出那个理由

结语:你其实没落下多少

回头看这六章。你离开了一年半,而真正需要你重新学的东西,列出来是这样:

  • 一套新的导航 API —— 但它比旧的简单,因为它把返回栈还给了你。
  • 一个可以删代码的编译器改进 —— 强跳过让你删掉那些 @Immutable
  • 一个新的 DI 选项 —— 而 Hilt 依然是默认答案。
  • 两条平台强制 —— edge-to-edge 和大屏不锁方向。

就这些。而没变的东西是:

  • 三层分层。
  • 单向数据流。
  • 唯一真相。
  • Repository 返回 Flow
  • 状态提升。
▣ 承重

你会发现,那份「没变」的清单,才是这门手艺真正的本体。它已经稳定了五年, 而且大概率会再稳定五年 —— 因为它讲的不是某个 API,是「状态该由谁负责」这类结构性的问题, 而这类问题的答案不会因为出了个新库就变。

变的永远是地基:编译器、构建工具、导航库、DI 框架。它们每两年换一茬,而且以后还会换

所以真正值得你花时间的,是分清哪些是承重墙,哪些是可以随时拆掉重砌的隔断。 这本书如果只留下一句话,就是这句。

✚ 最后一次动手

回到你离开前的那个项目。别急着重构,先做一件事:画出它的模块依赖图, 然后拿上面那个 Demo 的三条铁律去对一遍。

我赌你会找到至少一条 feature 之间的横向依赖。那就是你回归后的第一个任务 —— 它比升级到 Nav3 重要得多。