数据层与 DI 的新格局
这一章有两个主题,而它们恰好一个「几乎没变」、一个「变得最热闹」。数据层的形状还是那个形状 —— 但我想让你亲眼看清一件事:「离线优先」根本不是一个你要去实现的功能,它是 SSOT 这个结构的副产品,你把结构搭对了,离线可用是白送的。而 DI 那边,你离开时「DI」几乎等于「Hilt」,现在它变成了一张需要真正做选择的表 —— 2026 年 4 月,一个叫 Metro 的新玩家转正了,它不是注解处理器,是编译器插件。
三个决定,就构成了整个数据层
第 1 章的 Repository 签名,其实压缩了三个决定:
refresh() 没有返回值。
单向
Flow。它压根不知道有网络这回事。
解耦
这三条听着像是「为了整洁」。下面这个模拟器会让你看到,它们其实是在换一样非常具体的东西。 先用默认的「有 SSOT」模式走一遍:进页面(在线)→ 断网 → 退出重进 → 点收藏 → 联网。 然后切到「无 SSOT」,把完全一样的操作再走一遍。
注意「有 SSOT」那条路径里发生了什么:没有任何一行代码是为「离线」写的。
没有 if (isOffline),没有缓存策略类,没有降级逻辑。
离线可用之所以成立,纯粹是因为 UI 本来就只读本地库 —— 而本地库不管有没有网都在那儿。 网络断了,只是「没人往库里写新数据」而已,读的那条路一点没受影响。
反过来看「无 SSOT」:UI 直连网络,于是断网 = 没数据。你要想让它离线可用, 就得额外写一层缓存 —— 而那层缓存,就是你亲手造出来的「第二份真相」, 以及随之而来的所有同步 bug。
好架构的标志:你想要的性质是「推导出来」的,不是「实现出来」的。
Room 还是 SQLDelight
Now in Android 用 Room 2.8.3,Mihon 用 SQLDelight 2.3.2。 两个都活得很好。区别在哪?
| Room | SQLDelight | |
|---|---|---|
| 你写什么 | Kotlin 接口 + @Query("SELECT ...") 注解 |
.sq 文件里的纯 SQL,它生成 Kotlin |
| 谁是主语 | Kotlin 是主,SQL 是嵌在字符串里的客 | SQL 是主,Kotlin 是生成出来的 |
| SQL 写错了 | 编译期能查(Room 会验证) | 编译期能查(而且是拿真的 SQLite 语法验证) |
| 复杂查询 | 注解里塞一大坨 SQL 字符串,写着别扭 | 就是在写 SQL,该怎么写怎么写 |
| 多平台 | 支持(2.7 起) | 生来就是 KMP 的 |
| 生态 / 招人 | 官方,人人都会 | 小众一些 |
Room 让你尽量待在 Kotlin 里,SQL 是不得已才写的字符串。SQLDelight 反过来: SQL 才是真相,Kotlin 是它的投影。
Mihon 那种「一堆复杂的漫画库查询、过滤、排序」的场景,SQL 直接写显然更顺手。
而大多数商业 app 的查询就是 SELECT * WHERE id = ? 级别的 —— 那 Room 的便利就压倒一切。
没做过 KMP、查询也不复杂?用 Room,别折腾。
网络:Mihon 为什么连 Retrofit 都不要
三个项目,三个答案:
| 项目 | 用什么 | 为什么 |
|---|---|---|
| Now in Android | Retrofit 2.11 + kotlinx.serialization | 标准的 REST + JSON API。Retrofit 的甜点区 |
| DroidKaigi | Ktor Client | 要 KMP。Ktor 是 Kotlin 原生的多平台 HTTP 客户端 |
| Mihon | 裸 OkHttp 5.4 | 见下 |
Retrofit 是干什么的?把一个 REST 接口声明成一个 Kotlin interface ——
@GET("articles/{id}") suspend fun get(@Path("id") id: String): Article。
它的全部价值,建立在一个前提上:你事先知道那个 API 长什么样。
而 Mihon 抓的是几百个漫画网站的 HTML 页面。每个源的结构都不一样,而且随时会变 (网站改版了,扩展作者就更新一版扩展)。它要做的是「发个请求,把 HTML 拿回来,用 CSS 选择器扒」。
在这种场景里,Retrofit 那层「把 API 声明成接口」的抽象没有任何东西可抽象。
它只会挡在你和 Request / Response 中间。所以 Mihon 直接用 OkHttp ——
那本来就是 Retrofit 底下的那一层。
这是全书第三次出现同一个模式了:Mihon 拒绝的每一样东西,都是因为那样东西要解决的问题,它压根没有。 (不用 ViewModel:它不吃 Activity 重建;不用 Hilt:它的依赖图在运行时才成形;不用 Retrofit:它的 API 没有形状。)
DI:你离开时它还叫「Hilt」
2024 年中,Android 的 DI 讨论基本结束了:用 Hilt。2026 年,它重新打开了。
新玩家是 Metro(作者 Zac Sweers,前 Slack / Square)。它在 2026 年 4 月 27 日发布 1.0。
Dagger / Hilt / kotlin-inject 都是注解处理器(KAPT / KSP):它们在编译时读你的注解, 生成一大堆 Kotlin / Java 源码,再把那些源码编译一遍。
Metro 是一个 Kotlin 编译器插件。它直接在编译器内部(FIR + IR)干活 —— 不生成中间源码,不多跑一轮编译。
后果:
- 构建快。省掉了整个代码生成 + 二次编译的环节。
- 天生多平台。它是 Kotlin 编译器插件,Kotlin 能编到哪它就到哪(JVM / Native / JS / Wasm)。
- 报错在编译器里给,是 Kotlin 原生的诊断信息,不是「生成的代码编译失败了」那种二手错误。
它长这样,如果你写过 Dagger,基本不用学:
@DependencyGraph(AppScope::class)
interface AppGraph {
val searchRepository: SearchRepository
@Provides fun provideHttp(): OkHttpClient = OkHttpClient()
}
@Inject
@SingleIn(AppScope::class)
class SearchRepository(private val http: OkHttpClient) // 就是构造注入,老一套
Vinted(欧洲那个二手交易平台)的 Android 团队在 2026 年 2 月完成了从 Dagger 到 Metro 的全量迁移。
迁移之所以可行,是因为 Metro 支持组件级互操作 —— 一个 Metro graph 可以通过
@Includes 依赖现有的 Dagger 组件和 kotlin-inject 组件。
你可以一个模块一个模块地搬,不用停下来做大爆炸式重写。这在真实项目里是决定性的。
那到底选哪个
| Hilt | Metro | Koin | Injekt / 服务定位器 | |
|---|---|---|---|---|
| 何时报错 | 编译期 | 编译期 | 运行期 | 运行期 |
| 构建速度 | 慢(注解处理) | 快(编译器插件) | 快(无生成) | 快(无生成) |
| 多平台 | Android only | 是 | 是 | — |
| Android 集成度 | 最高(@HiltViewModel 等开箱即用) |
要自己接一点 | 要自己接一点 | 自己接 |
| 生态 / 招人 | 压倒性 | 新 | 不错 | 小众 |
- 纯 Android、团队规模正常 → Hilt。它仍然是默认答案,官方样板还在用它。不要为了新而换。
- 构建时间已经痛到影响心情,或者要做 KMP → 认真看看 Metro。它能和 Dagger 共存,所以可以增量试。
- 小项目 / 原型 / 团队更喜欢 DSL → Koin。接受「缺依赖要跑起来才知道」这个代价。
- 依赖图在运行时才成形(插件化)→ 服务定位器。不是偷懒,是编译期 DI 在那种系统里物理上无效(第 4 章)。
@Composable fun Screen(repo: Repository = hiltInject()) —— 别这么干。
依赖应该注进状态持有者(ViewModel / ScreenModel / Presenter),而不是注进 composable。
否则你的 composable 就不再是纯函数 —— 它没法在 @Preview 里渲染,
没法在测试里单独跑,也没法在别的上下文里复用。
composable 应该只认参数。它需要什么,你就传给它。
- 离线可用是 SSOT 的副产品,不是一个功能。你把「UI 只读本地库」这条守住,它自己就出现了。
- Room vs SQLDelight = 你更信 Kotlin 还是更信 SQL。查询不复杂就用 Room,别折腾。
- Mihon 拒绝的每一样东西,都是因为它没有那个问题。这是读开源项目的正确方式:看它的处境,而不是抄它的选择。
- Metro 是编译器插件,不是注解处理器 —— 快、多平台、能和 Dagger 共存所以可增量迁移。2026-04 转正,Vinted 已全量用上。
- Hilt 仍然是默认答案。换它需要一个能说出口的理由。
- 别往 composable 里注入依赖。注进状态持有者。
1. 打开你的项目,找一个页面,问:把网线拔了,它还能显示上次的内容吗?如果不能,顺着那条数据路径往回找 —— 你会在某一层发现 UI 直接(或间接)依赖了网络的返回值。
2. 找一个 refresh() 或 loadData() 方法,看它有没有返回值。如果有,那就是一个 SSOT 的漏点:调用方现在有能力拿着这份数据自己干点什么了。
三档流派都拆完了。最后一章收口:把前五章的所有决定落成一个真的能跑起来的项目骨架 —— 模块怎么切、版本目录怎么写、convention plugin 长什么样,以及最后那张你真正要用的选型决策表。