幕 III · 三档流派CH 05进度 05/06

数据层与 DI 的新格局

这一章有两个主题,而它们恰好一个「几乎没变」、一个「变得最热闹」。数据层的形状还是那个形状 —— 但我想让你亲眼看清一件事:「离线优先」根本不是一个你要去实现的功能,它是 SSOT 这个结构的副产品,你把结构搭对了,离线可用是白送的。而 DI 那边,你离开时「DI」几乎等于「Hilt」,现在它变成了一张需要真正做选择的表 —— 2026 年 4 月,一个叫 Metro 的新玩家转正了,它不是注解处理器,是编译器插件。

SSOT离线优先Room vs SQLDelightMihon 为什么不要 RetrofitMetro

三个决定,就构成了整个数据层

第 1 章的 Repository 签名,其实压缩了三个决定:

真相存哪 本地库。不是服务器,不是内存,不是 ViewModel。就是磁盘上那个数据库。 SSOT
网络干嘛 网络只负责把数据写进库。它不直接喂给 UIrefresh() 没有返回值。 单向
UI 读谁 UI 只订阅本地库的 Flow。它压根不知道有网络这回事。 解耦

这三条听着像是「为了整洁」。下面这个模拟器会让你看到,它们其实是在换一样非常具体的东西。 先用默认的「有 SSOT」模式走一遍:进页面(在线)→ 断网 → 退出重进 → 点收藏 → 联网。 然后切到「无 SSOT」,把完全一样的操作再走一遍。

◆ 判词 · 你没有「实现」离线可用,你只是没有阻止它

注意「有 SSOT」那条路径里发生了什么:没有任何一行代码是为「离线」写的。 没有 if (isOffline),没有缓存策略类,没有降级逻辑。

离线可用之所以成立,纯粹是因为 UI 本来就只读本地库 —— 而本地库不管有没有网都在那儿。 网络断了,只是「没人往库里写新数据」而已,读的那条路一点没受影响。

反过来看「无 SSOT」:UI 直连网络,于是断网 = 没数据。你要想让它离线可用, 就得额外写一层缓存 —— 而那层缓存,就是你亲手造出来的「第二份真相」, 以及随之而来的所有同步 bug。

好架构的标志:你想要的性质是「推导出来」的,不是「实现出来」的。

Room 还是 SQLDelight

Now in Android 用 Room 2.8.3,Mihon 用 SQLDelight 2.3.2。 两个都活得很好。区别在哪?

RoomSQLDelight
你写什么 Kotlin 接口 + @Query("SELECT ...") 注解 .sq 文件里的纯 SQL,它生成 Kotlin
谁是主语 Kotlin 是主,SQL 是嵌在字符串里的客 SQL 是主,Kotlin 是生成出来的
SQL 写错了 编译期能查(Room 会验证) 编译期能查(而且是拿真的 SQLite 语法验证)
复杂查询 注解里塞一大坨 SQL 字符串,写着别扭 就是在写 SQL,该怎么写怎么写
多平台 支持(2.7 起) 生来就是 KMP 的
生态 / 招人 官方,人人都会 小众一些
◆ 判词 · 这个选择的本质是:你更信 Kotlin,还是更信 SQL

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 的价值,在 Mihon 那儿正好等于零

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

◆ 判词 · Metro 的关键词只有一个:它不是注解处理器

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 已经全量迁过去了

Vinted(欧洲那个二手交易平台)的 Android 团队在 2026 年 2 月完成了从 Dagger 到 Metro 的全量迁移

迁移之所以可行,是因为 Metro 支持组件级互操作 —— 一个 Metro graph 可以通过 @Includes 依赖现有的 Dagger 组件和 kotlin-inject 组件。 你可以一个模块一个模块地搬,不用停下来做大爆炸式重写。这在真实项目里是决定性的。

那到底选哪个

HiltMetroKoinInjekt / 服务定位器
何时报错 编译期 编译期 运行期 运行期
构建速度 慢(注解处理) 快(编译器插件) 快(无生成) 快(无生成)
多平台 Android only
Android 集成度 最高(@HiltViewModel 等开箱即用) 要自己接一点 要自己接一点 自己接
生态 / 招人 压倒性 不错 小众
▣ 一句话选型
  • 纯 Android、团队规模正常 → Hilt。它仍然是默认答案,官方样板还在用它。不要为了新而换。
  • 构建时间已经痛到影响心情,或者要做 KMP → 认真看看 Metro。它能和 Dagger 共存,所以可以增量试。
  • 小项目 / 原型 / 团队更喜欢 DSL → Koin。接受「缺依赖要跑起来才知道」这个代价。
  • 依赖图在运行时才成形(插件化)→ 服务定位器。不是偷懒,是编译期 DI 在那种系统里物理上无效(第 4 章)。
⚠ 一个不该踩的坑:别在 Composable 里直接注入

@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 长什么样,以及最后那张你真正要用的选型决策表。