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

状态住在哪

「用 ViewModel」—— 这个答案你已经默认了六年,以至于都忘了它是个答案,而不是定义。这一章从一个刁钻的问题开始:ViewModel 存在的唯一理由是什么?(不是「持有状态」——那是它的用途,不是它为什么被发明出来。)想清楚这个,你就能理解为什么有五百万用户的 Mihon 敢完全不用它,以及为什么另一派会提出一个听起来很疯的主意:把 Compose 运行时本身当成状态引擎。

ViewModel 的真正理由WhileSubscribed(5_000)Voyager ScreenModelInjektCircuit / Molecule

ViewModel 是来解决什么问题的?

标准答案是「持有 UI 状态、把业务逻辑从 Activity 里挪出来」。这个答案不对 —— 或者说, 它只描述了 ViewModel 的用法,没解释它的存在

因为要把逻辑挪出 Activity,你随便建个普通类就行了。为什么非得是 ViewModel 这个特定的基类?

◆ 判词 · ViewModel 存在的唯一理由:活过配置变更

Android 有一个非常独特的、别的平台都没有的丑陋事实:用户转个屏,你的 Activity 就被销毁重建了。 一切 Activity 里的东西灰飞烟灭 —— 包括你正在进行的网络请求、正在输入的搜索词。

ViewModel 是 Google 给这个伤口打的补丁:它挂在一个叫 ViewModelStore 的东西上, 而 ViewModelStore 在配置变更中幸存。仅此而已。

把这句话记牢,这一章后面全靠它。ViewModel = 一个能熬过转屏的容器。 它所有的设计(onCleared()viewModelScopeSavedStateHandle)都是围绕这一件事长出来的。

那么下一个问题就自然了:如果我不吃「转屏销毁」这个痛呢?

三派,同一个搜索屏幕

与其抽象地比较,不如让它们做同一件事。下面这个 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 退到后台了它还在收,白白烧电和流量。
◆ 判词 · 那个 5_000 毫秒是整段代码里最精巧的一处

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 存在的唯一理由是活过转屏。

⌗ 现场 · Mihon 不吃那个痛,所以不需要那个补丁
  • 它的页面不是 Activity。Mihon 用 Voyager,页面是 Screen 对象,活在 Voyager 自己的 Navigator 栈里。ScreenModel 的生命周期跟着 Screen 走 —— Voyager 自己就保证了它熬过配置变更,不需要借 Android 的 ViewModelStore
  • 它是个单 Activity 的阅读器。没有复杂的 Activity 栈,没有需要跨 Activity 共享的状态。 Android 那套生命周期的复杂度,它大部分都没碰到。

结论:它不是「反对 ViewModel」,它是「不需要 ViewModel 要解决的那个问题」。 这才是读开源项目该读出来的东西 —— 不是抄它的选择,是理解它的处境。

那 Injekt 呢?一个运行时服务定位器,在 Hilt 面前看着像倒退十年。它的代价很实在:

⚠ 服务定位器的代价:错误从编译期推迟到了运行期

Hilt 少绑一个依赖,编译不过。Injekt 少注册一个依赖,编译得好好的,用户点到那个页面才崩

这是实打实的退步。所以问题变成:Mihon 用这个代价,换到了什么?

⌗ 现场 · 它换到的东西,是 Hilt 根本给不了的

Mihon 的漫画源是「扩展」—— 一个个在运行时动态加载的独立 APK。

请想一下这意味着什么:一个编译期 DI 框架(Hilt / Dagger)的全部价值,建立在「编译时我能看见整张依赖图」这个前提上。 而 Mihon 的依赖图在编译时根本不存在 —— 用户明天装一个新的漫画源扩展,那个扩展需要哪些依赖, 你今天编译的时候怎么可能知道?

在一个依赖图必须在运行时才成形的系统里,编译期 DI 是无效的。Injekt 不是 Mihon 的偷懒, 是它那个插件化架构的必然选择。顺带的好处:没有注解处理器,构建快得多。

三 · Compose 原生:Presenter 本身就是个 @Composable

DroidKaigi · Circuit(Slack)/ Molecule(Cash App)

这一派的出发点是一个观察 —— 而这个观察,一旦你听懂了就再也忘不掉:

◆ 判词 · Compose 不是一个 UI 框架,它是一个「自动重算」的运行时

剥掉 UI,Compose 运行时的本质是:你读了什么状态,它记着;那个状态一变,它自动把读过它的那段代码重跑一遍。

这是一台通用的响应式计算引擎rememberLaunchedEffectderivedStateOf —— 这些工具跟「画界面」没有半点必然联系。

那么问题来了:既然我们已经有了这台引擎,为什么在状态管理层还要再用一套完全不同的东西(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 的心智写的。
⌗ 现场 · DroidKaigi 2025 的会议 app 就是这么搭的

它的 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.EagerlySharingStarted.Lazily。问自己:这里为什么不是 WhileSubscribed(5_000)?大概率没有理由,只是当年抄错了。

状态的归属讲完了。下一章往下走一层 —— 数据从哪来,依赖怎么进来。 你会看到「离线优先」根本不是一个功能,而是 SSOT 这个结构自己长出来的副产品; 以及 DI 这个你以为早就尘埃落定的领域,在 2026 年多了一个新玩家。