状态住在哪
「用 ViewModel」—— 这个答案你已经默认了六年,以至于都忘了它是个答案,而不是定义。这一章从一个刁钻的问题开始:ViewModel 存在的唯一理由是什么?(不是「持有状态」——那是它的用途,不是它为什么被发明出来。)想清楚这个,你就能理解为什么有五百万用户的 Mihon 敢完全不用它,以及为什么另一派会提出一个听起来很疯的主意:把 Compose 运行时本身当成状态引擎。
ViewModel 是来解决什么问题的?
标准答案是「持有 UI 状态、把业务逻辑从 Activity 里挪出来」。这个答案不对 —— 或者说, 它只描述了 ViewModel 的用法,没解释它的存在。
因为要把逻辑挪出 Activity,你随便建个普通类就行了。为什么非得是 ViewModel 这个特定的基类?
Android 有一个非常独特的、别的平台都没有的丑陋事实:用户转个屏,你的 Activity 就被销毁重建了。 一切 Activity 里的东西灰飞烟灭 —— 包括你正在进行的网络请求、正在输入的搜索词。
ViewModel 是 Google 给这个伤口打的补丁:它挂在一个叫 ViewModelStore 的东西上,
而 ViewModelStore 在配置变更中幸存。仅此而已。
把这句话记牢,这一章后面全靠它。ViewModel = 一个能熬过转屏的容器。
它所有的设计(onCleared()、viewModelScope、SavedStateHandle)都是围绕这一件事长出来的。
那么下一个问题就自然了:如果我不吃「转屏销毁」这个痛呢?
三派,同一个搜索屏幕
与其抽象地比较,不如让它们做同一件事。下面这个 Demo 里,三派并排实现同一个搜索屏幕: 用户输入 → 防抖 → 查数据 → 出结果。点「下一步」,三栏同时推进 —— 你可以横着读: 同一时刻,三派各自在干什么。
走完五步,那张对照表才是重点。现在我们逐派拆开讲。
一 · 官方正统:ViewModel + StateFlow
Now in Android
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repo: SearchRepository,
savedState: SavedStateHandle, // ← 进程死了都能恢复的那份
) : ViewModel() {
private val query = MutableStateFlow("")
val uiState: StateFlow<SearchUiState> = query
.debounce(300)
.flatMapLatest { q -> repo.search(q) } // 新的输入来了?旧的请求直接取消
.map(SearchUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // ← 这个 5 秒,下面细说
initialValue = SearchUiState.Loading,
)
fun onQueryChange(q: String) { query.value = q }
}
UI 侧:
val state by viewModel.uiState.collectAsStateWithLifecycle() // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // 不是 collectAsState()!后者不感知生命周期 —— // app 退到后台了它还在收,白白烧电和流量。
WhileSubscribed(5_000) 的意思是:最后一个订阅者消失 5 秒后,才停掉上游。
为什么是「5 秒」这么一个奇怪的数?因为它要同时满足两个互相矛盾的需求:
- 转屏时别停。转屏会让 UI 短暂取消订阅(Activity 重建)。如果立刻停掉上游, 重建后就得重新发一遍网络请求 —— 用户转个屏,列表闪一下重新加载。5 秒的宽限期让它熬过去。
- 用户真走了就得停。用户按 home 键离开,5 秒后上游停止,不再占着数据库监听和网络连接。
一个数字,同时解决了「别在转屏时重启」和「别在后台空烧」。这是官方那套里我最欣赏的一处设计,
也是最容易被抄错的一处(很多人写成 SharingStarted.Eagerly,然后奇怪为什么后台还在跑请求)。
二 · 社区实用主义:Mihon 压根不用 ViewModel
Mihon · Voyager StateScreenModel + Injekt
这是本书最值得琢磨的一个案例。Mihon 是有数百万用户的漫画阅读器, 它的技术栈里没有 ViewModel、没有 Hilt、没有 Room、没有 Retrofit —— 官方推荐的四大件,一件没要。
class SearchScreenModel(
private val repo: SearchRepository = Injekt.get(), // ← 服务定位器,不是构造注入
) : StateScreenModel<SearchScreenModel.State>(State()) {
fun onQueryChange(q: String) {
mutableState.update { it.copy(query = q) } // ← 直接改自己那份 state
screenModelScope.launch { // ← 不是 viewModelScope
val results = repo.search(q)
mutableState.update { it.copy(results = results) }
}
}
data class State(val query: String = "", val results: List<Manga> = emptyList())
}
它凭什么敢?回到本章开头那句话 —— ViewModel 存在的唯一理由是活过转屏。
-
它的页面不是 Activity。Mihon 用 Voyager,页面是
Screen对象,活在 Voyager 自己的Navigator栈里。ScreenModel的生命周期跟着Screen走 —— Voyager 自己就保证了它熬过配置变更,不需要借 Android 的ViewModelStore。 - 它是个单 Activity 的阅读器。没有复杂的 Activity 栈,没有需要跨 Activity 共享的状态。 Android 那套生命周期的复杂度,它大部分都没碰到。
结论:它不是「反对 ViewModel」,它是「不需要 ViewModel 要解决的那个问题」。 这才是读开源项目该读出来的东西 —— 不是抄它的选择,是理解它的处境。
那 Injekt 呢?一个运行时服务定位器,在 Hilt 面前看着像倒退十年。它的代价很实在:
Hilt 少绑一个依赖,编译不过。Injekt 少注册一个依赖,编译得好好的,用户点到那个页面才崩。
这是实打实的退步。所以问题变成:Mihon 用这个代价,换到了什么?
Mihon 的漫画源是「扩展」—— 一个个在运行时动态加载的独立 APK。
请想一下这意味着什么:一个编译期 DI 框架(Hilt / Dagger)的全部价值,建立在「编译时我能看见整张依赖图」这个前提上。 而 Mihon 的依赖图在编译时根本不存在 —— 用户明天装一个新的漫画源扩展,那个扩展需要哪些依赖, 你今天编译的时候怎么可能知道?
在一个依赖图必须在运行时才成形的系统里,编译期 DI 是无效的。Injekt 不是 Mihon 的偷懒, 是它那个插件化架构的必然选择。顺带的好处:没有注解处理器,构建快得多。
三 · Compose 原生:Presenter 本身就是个 @Composable
DroidKaigi · Circuit(Slack)/ Molecule(Cash App)
这一派的出发点是一个观察 —— 而这个观察,一旦你听懂了就再也忘不掉:
剥掉 UI,Compose 运行时的本质是:你读了什么状态,它记着;那个状态一变,它自动把读过它的那段代码重跑一遍。
这是一台通用的响应式计算引擎。remember、LaunchedEffect、
derivedStateOf —— 这些工具跟「画界面」没有半点必然联系。
那么问题来了:既然我们已经有了这台引擎,为什么在状态管理层还要再用一套完全不同的东西(Flow 操作符)?
Circuit 的答案:presenter 就是个 @Composable 函数,它的返回值就是 state。
class SearchPresenter(private val repo: SearchRepository) : Presenter<SearchState> {
@Composable // ← 是的,presenter 是 @Composable
override fun present(): SearchState { // ← 它 return 一个 state
var query by remember { mutableStateOf("") } // 就是 Compose 那套
val results by produceState(emptyList(), query) { // 异步也是 Compose 那套
delay(300) // 防抖
value = repo.search(query)
}
return SearchState(query, results) { event -> // 事件回调塞在 state 里
when (event) {
is QueryChanged -> query = event.q
}
}
}
}
没有 MutableStateFlow,没有 debounce,没有 flatMapLatest,没有 stateIn。
防抖是一句 delay(300),取消是 produceState 的 key 变了自动重启。
那这个 @Composable 函数怎么变成一条状态流?这就是 Molecule 干的事:
// Molecule:把 Compose 运行时跑起来,产出一个 StateFlow
val states: StateFlow<SearchState> = scope.launchMolecule(RecompositionMode.ContextClock) {
presenter.present() // ← present() 每重组一次,就吐出一版新的 state
}
// 于是 UI 层拿到的,还是一个普普通通的 StateFlow。
// 但产生它的那段逻辑,是用 Compose 的心智写的。
它的 libs.versions.toml 里同时躺着 Molecule 2.1、Soil、Rin,导航是 Navigation 3 +
material3.adaptive:adaptive-navigation3,UI 是 Compose Multiplatform。
这基本就是这一派的完全体。
Circuit 则在 Slack 的生产环境里跑着 —— 它不是玩具。
Presenter 是 @Composable,意味着它会被反复重跑。所有 Compose 的规矩在这儿全都适用:
不能在里面写有副作用的代码、不能假设它只跑一次、忘了 remember 就会每次重置。
对已经吃透 Compose 的人,这是心智统一(UI 和状态用同一套规则); 对还在跟重组搏斗的人,这是把战场扩大了一倍。
而且调试栈会更绕:一个状态为什么变了,你得从重组的角度去想,而不是从「谁调了 emit」去想。
所以,你该选哪个
| 如果你…… | 选 | 为什么 |
|---|---|---|
| 在做一个普通的商业 Android app | ViewModel | 生态、招人、文档、AI 的训练数据全在这儿。不要为了新而新 |
| 团队里没人吃透 Compose 重组 | ViewModel | 别把战场扩大到状态层。先把 UI 层的重组搞明白 |
| 依赖图必须在运行时才成形(插件化 / 动态加载) | ScreenModel + 服务定位器 | 编译期 DI 在这种系统里物理上无效。Mihon 的处境 |
| 要做 KMP,想让状态层也共享 | Composable Presenter | Compose 运行时是跨平台的;ViewModel 虽然也多平台化了,但它的生命周期语义仍然是 Android 味的 |
| 屏幕状态极其复杂(消息、邮件类) | Circuit | 这正是 Slack 造它的原因。每个屏幕的状态自成一体、可独立测试 |
| 只是觉得 Flow 操作符链写着累 | 别换框架 | 换框架的成本远大于学会 flatMapLatest 的成本 |
- ViewModel 存在的唯一理由是活过配置变更。它不是「持有状态」的同义词。想清这一点,你才看得懂另外两派。
WhileSubscribed(5_000)里那 5 秒,同时买到了「转屏不重启」和「后台不空烧」。别改成Eagerly。collectAsStateWithLifecycle(),不是collectAsState()。后者在后台还在收。- Mihon 用服务定位器不是倒退 —— 它的漫画源是运行时动态加载的 APK,依赖图在编译期根本不存在,编译期 DI 物理上无效。
- Compose 运行时是一台通用的响应式引擎,不只能画 UI。Circuit / Molecule 把它拿来当状态引擎用,防抖就是一句
delay(300)。 - 默认仍然选 ViewModel。另外两派要有具体的、能说出口的理由。
1. 在你的项目里搜 collectAsState()(不带 WithLifecycle 的),每一处都是一个后台空烧的嫌疑点。
2. 搜 SharingStarted.Eagerly 和 SharingStarted.Lazily。问自己:这里为什么不是 WhileSubscribed(5_000)?大概率没有理由,只是当年抄错了。
状态的归属讲完了。下一章往下走一层 —— 数据从哪来,依赖怎么进来。 你会看到「离线优先」根本不是一个功能,而是 SSOT 这个结构自己长出来的副产品; 以及 DI 这个你以为早就尘埃落定的领域,在 2026 年多了一个新玩家。