ArturoYi
Android
Compose 从 remember 排到 ViewModel 的 UiState。记账应用用仓库的 Flow 合成一屏状态,一次事件走 SharedFlow。Orbit、Mavericks、Molecule、Circuit、MVIKotlin 规定动作和副作用怎么走。

我把 Android 的状态按活多久分成四层。先问三件事:状态放在哪,谁可以改,改完界面怎么跟上。

Compose 用 remember 回答最短的那一截。一屏的字段和动作放进 ViewModel,界面读一份 UiState。Room 和 DataStore 的 Flow 活在进程里的仓库上。导航栈单独记在 初探 Navigation。Orbit、Mavericks、Molecule、Circuit、MVIKotlin 再规定动作和副作用走哪条函数。界面最后仍是读这份状态。

记账应用是手写的这一套:仓库出 Flow,ViewModel 收成 StateFlow,Route 用 collectAsStateWithLifecycle() 订阅,纯展示的 Screen 只接收 uiState 和回调。下面先按这个记,再看三方库把同一条路收成什么形状。

四层

这一块组合
  remember                 重组还在,离开组合就没了
  rememberSaveable         旋转和进程重建还在,值要能保存
  derivedStateOf           从别的 Compose 状态算出来

这一屏
  ViewModel
  StateFlow<UiState>       当前值,后来的订阅者马上拿到
  SharedFlow               只发生一次,不留在状态里
  viewModel(key = …)       同一个存储里区分实例

这一进程
  Repository               单例
  Room / DataStore 的 Flow 查询结果和偏好

导航
  NavBackStack、当前标签    哪一页在栈上,和这一页的字段分开
层谁持有旋转之后进程被杀之后
remember这块组合丢丢
rememberSaveable这块组合,经 SavedState还在还在
ViewModel 里的 StateFlowViewModelStore还在重新创建,初值重新来
仓库里的 Flow进程内单例还在从数据库或 DataStore 再读
返回栈上的 NavKeyrememberNavBackStack还在键能序列化就还在

组合里的值

一个对话框开没开、一次还没提交的输入,先放在调用它的组合里。remember 把存储留在这块组合的身份上。组合函数再次执行时,拿到的是上次的值。

CreateCategoryDialog.kt
@Composable
internal fun AddExpenseRoute(viewModel: AddExpenseViewModel) {
    // 方式:rememberSaveable
    // 对话框开关跟着这块组合走。旋转之后还开着。
    // 进程重建时布尔值能写进 SavedState,所以用 rememberSaveable。
    var showCreate by rememberSaveable { mutableStateOf(false) }
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    AddExpenseScreen(
        uiState = uiState,
        showCreateCategory = showCreate,
        onShowCreateCategory = { showCreate = true },
    )
}

纯展示的 Screen 不自己 remember 这份开关。开关从 Route 传进来,预览时直接给 true 或 false。

能从别的状态算出来的值做成计算,例如金额字符串空不空。再存一份 remember,用 LaunchedEffect 去同步,两份会分叉。derivedStateOf 留给「输入每次变,但派生结果很少变」:过滤条件在动,过滤后的列表引用要稳定,避免读到它的子组合每次都重组。

remember 的初值表达式每次进入组合都会执行,Compose 只采纳第一次的结果。列表、账户、网络结果放在下一层的 ViewModel 里。

一屏一份 UiState

这一屏要给界面的字段收成一个 data class。用户做的事收成 sealed interface。ViewModel 持有这两样,Screen 只画、只把事件交上去。

记账首页的当前账户、本月收支、预算和最近账单在 BillHomeUiState 里。点账户芯片是 BillHomeUiEvent.Select。ViewModel 里 onEvent 调用仓库把「正在展示的账户」写回去,首页的 Flow 随后发出新的一行,uiState 跟着变。界面不写数据库,也不改 uiState 的字段。

HomeViewModel.kt
data class HomeUiState(
    val loading: Boolean = true,
    val monthExpense: Long = 0,
)

sealed interface HomeUiEvent {
    data class SelectAccount(val accountId: Long) : HomeUiEvent
}

class HomeViewModel(
    private val stats: StatsRepository,
) : ViewModel() {
    val uiState: StateFlow<HomeUiState> = stats.observeMonth()
        .map { summary ->
            HomeUiState(loading = false, monthExpense = summary.expenseAmount)
        }
        .stateIn(
            scope = viewModelScope,
            // 订阅者离开 5 秒内再回来,上游查询不断。
            // 再久就把上游停掉,下次进入重新收集。
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = HomeUiState(),
        )

    fun onEvent(event: HomeUiEvent) {
        when (event) {
            is HomeUiEvent.SelectAccount -> select(event.accountId)
        }
    }

    private fun select(accountId: Long) {
        viewModelScope.launch { stats.setDisplayed(accountId) }
    }
}

stateIn 把冷的 Flow 收成热的 StateFlow。后来的订阅者马上拿到当前值,首帧用 initialValue,所以加载中是一份明确的空状态,金额先是 0 这个事实写在 loading 上。开屏等到首页 loading 变成 false(或最多再等 2 秒)才揭开,避免数字先显示 0 再跳成真实值。

Route 这样订阅:

HomeRoute.kt
@Composable
internal fun HomeRoute(
    viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    HomeScreen(
        uiState = uiState,
        onSelectAccount = { id -> viewModel.onEvent(HomeUiEvent.SelectAccount(id)) },
    )
}

collectAsStateWithLifecycle() 在生命周期低于 STARTED 时停止收集。页面退到后台,这段组合不再因为数据库更新而重组。

SharingStarted 按这一屏会不会提前被订上来选:

  • 可以离开的页面用 WhileSubscribed(5_000)。首页、设置列表、预算都是这种。
  • 进程一启动就要的值用 Eagerly。主题偏好在 Activity 里就收集,启动动画期间 DataStore 有时间读完。
  • 记一笔也用 Eagerly。账单页先把这个 ViewModel 订上,用户点加号时分类和账户已经在状态里。

ViewModel 里也可以放 mutableStateOf,Compose 直接读,不必 collectAsState。上游已经是 Room 的 Flow 时,我继续用 StateFlow:combine、stateIn 和生命周期收集是同一套。

界面上的行用专门的 UI 类型,例如 BillRowUi、AccountChoice。Room 的 Bill、Account 停在仓库边界。分类名在 ViewModel 里按 id 填好,Screen 不再查表。

只发生一次的结果

保存成功、新建分类失败,写进 UiState 会留下。旋转或重新进入组合时,Route 会再看到这份值,返回或弹窗会再走一次。

这种结果用 SharedFlow。不设重放,后来的订阅者拿不到已经发出的那次。Route 在 LaunchedEffect(viewModel) 里收集,然后调用 onBack 或关掉对话框。

AddExpenseViewModel.kt
private val _finished = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val finished: SharedFlow<Unit> = _finished.asSharedFlow()

private fun finishSave(token: Int) {
    if (token != saveToken) return
    _finished.tryEmit(Unit)
}
AddExpenseRoute.kt
LaunchedEffect(viewModel) {
    viewModel.finished.collect { onBack() }
}

extraBufferCapacity = 1 让 ViewModel 在没有订阅者的瞬间也能 tryEmit 成功一次。保存用一个自增的 saveToken:离开页面时作废进行中的那次,迟到的成功不会把新表单清掉,也不会把已经离开的页面再退一次。

ViewModel 到此为止。它不持有 AppNavigator,也不调用 pop。下一页去哪,由路由表里的回调决定。

仓库里的 Flow

查询是 Flow,写入是 suspend 函数。BillRepository 用 Hilt 做成单例,转账在 withTransaction 里写两条并改两个账户余额。支出金额一变,预算按账单重新合计,仓库不另写一份「已花费」给界面缓存。

ViewModel 用 combine 把账户、账单、预算、分类和偏好收成 UiState。某一路还没来时,loading 保持 true。首页另外把账户条先暴露出来,正文继续等。正文还没跟上时,顶栏沿用和卡片同一份当前账户,切换账户时两边一起变。

偏好放 DataStore,经 AppPreferencesRepository 出去。设置页和主题读的是同一个 SettingsViewModel:Activity 用 preferences 决定深色和主题,设置列表用 uiState 画分组。写入只走仓库。

ViewModel 存在哪

viewModel() 找最近的 ViewModelStoreOwner。记账应用没有给每个导航入口挂 rememberViewModelStoreNavEntryDecorator(),所以实例落在 Activity 上。旋转不会新建,离开页面也不会清掉。

这带来两件具体的事。

账单页和开屏共用 BillHomeViewModel,不传 key。记一笔用 "add-expense-$typeCode",账单页和记一笔页因此是同一个实例。账户编辑用 "account-editor-${accountId ?: "new"}",新建和每一个账户各一份。

工厂只在这个 key 第一次创建时执行。之后再进,factory 里的新参数被忽略。全部账单目前只按类型取 ViewModel,没有把 accountId 写进 key。换一个账户再打开「查看全部」,列表仍是上一个账户。参数会变的页面,key 里带上那一段。

每个入口单独一个 ViewModelStore 时,离开页面实例会清掉,预加载就要改成别的做法:把要提前准备的数据放进仓库,页面自己的 ViewModel 进去再订。记账应用选 Activity 这一档,是为了让记一笔的表单和分类在打开之前就位。

进程被杀后 ViewModel 会新建。返回栈上的 NavKey 能还原,Route 再次组合时工厂用还原回来的 accountId 创建。草稿若只活在 ViewModel 的 MutableStateFlow 里,这次会丢。要活过进程重建的小字段,放进 SavedStateHandle,或者放在 rememberSaveable 里。大列表走仓库再查。

依赖用 viewModelFactory 从 Hilt EntryPoint 取仓库,构造函数再接收 accountId、typeCode。@HiltViewModel 适合没有导航参数的那一屏。参数来自路由时,工厂把参数和仓库拼在一起。

和导航分开

返回栈、当前标签是导航状态,rememberNavBackStack 和 rememberSaveable 持有。这一页的金额、选中的分类是页面状态。两边各有一份,通过 id 相连:路由把 accountId 交给 Route,Route 交给 ViewModel 工厂。

设置里的对话框放在设置 ViewModel 的短暂状态里,因为这份 ViewModel 挂在 Activity 上,旋转之后还在。记一笔的日期选择只属于那次编辑,放在 Route 的 rememberSaveable 里。

跳转、替换栈顶、dropUnlessResumed 写在 初探 Navigation。这篇只规定:页面状态的出口是 UiState 和 SharedFlow,导航器留在路由表。

三方状态管理

上面是手写的。状态一个 data class,动作一组类型,副作用在 viewModelScope 里,做完再写状态或发 SharedFlow。屏少的时候这样够用。屏一多,每页的 reduce、订阅和一次性结果会各写各的。

下面这些库规定的是这一屏的状态怎么变。Room、DataStore、仓库仍然留在原处。

名字它规定什么一次性结果我怎么看
RxJava一条可取消的事件流订阅里处理老工程的绑定层。新页面的请求我用 suspend
OrbitViewModel 上的 intent / reducepostSideEffect最接近现在这套,样板收进容器
Mavericks不可变 State,setState,Async状态里的 Async.Fail,或自己的事件Airbnb 系。状态默认可恢复
Molecule@Composable 算出模型,输出 StateFlow模型里的字段,或另开一条流逻辑想用 LaunchedEffect 写
Circuitpresent() 返回带 eventSink 的状态状态里的字段;导航走 Presenter 的 Navigator状态和页面栈要同一套时
MVIKotlinStore:Intent 进,State 出Label要时间旅行、要跨端时。常和 Decompose 一起用

同一类形状还有 Ballast、FlowRedux。动作进、状态出、副作用在旁边。下面六种够对照。

RxJava

Observable 表示以后还会来的值。BehaviorRelay 记住当前值,后来的订阅者马上拿到它。订阅放进 CompositeDisposable,控制器或收集方释放时一起取消。

RxCounterViewModel.kt
class RxCounterViewModel : ViewModel() {
    val count = BehaviorRelay.createDefault(0)

    fun increment() {
        count.accept(count.value!! + 1)
    }

    override fun onCleared() {
        // Relay 本身不持有界面。订阅在收集那一侧 dispose。
    }
}
RxCounterBind.kt
viewModel.count
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe { count -> label.text = count.toString() }
    .also { disposable.add(it) }

状态仍在 ViewModel 里。Rx 负责运送值。绑到界面的那一段可以用 Observable 的主线程调度;网络那一段失败在仓库里处理,界面收到的是已经变成状态的值。

新页面我用协程和 Flow。工程里已经有 Rx 的绑定,继续留在那些页面上。

Orbit

Orbit 把现在的 ViewModel 收成一个容器。状态是 data class,副作用是另一个类型。改状态只能走 reduce,一次性结果走 postSideEffect。

CounterContainer.kt
data class CounterState(val count: Int = 0)

sealed interface CounterSideEffect {
    data object Finished : CounterSideEffect
}

class CounterViewModel :
    ContainerHost<CounterState, CounterSideEffect>,
    ViewModel() {

    override val container = container<CounterState, CounterSideEffect>(CounterState())

    fun increment() = intent {
        reduce { state.copy(count = state.count + 1) }
    }

    fun save() = intent {
        // 请求放在 intent 里。完成后要么 reduce,要么 postSideEffect。
        postSideEffect(CounterSideEffect.Finished)
    }
}

界面用 Orbit 的收集函数。副作用的 lambda 跟着组合的生命周期,页面停在后台时不再接收。

CounterOrbitScreen.kt
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val state by viewModel.collectAsState()
    viewModel.collectSideEffect { effect ->
        when (effect) {
            CounterSideEffect.Finished -> { /* 交给导航 */ }
        }
    }
    Button(onClick = viewModel::increment) {
        Text("${state.count}")
    }
}

这和记账应用的 onEvent + SharedFlow 是同一条路。差别是 reduce 拿到的是容器里的当前 state,连续两次 increment 各在当时的值上加一,不用自己 update。上游 Flow 用 intent 里的 repeatOnSubscription 收集,订阅者离开就停,对应手写的 WhileSubscribed。

我在一屏的副作用已经多到要单测「这个动作之后状态是什么、发出了哪个副作用」时,会换成 Orbit。一个开关继续用现在的 onEvent。

Mavericks

Mavericks 也是一屏一份不可变状态,挂在 MavericksViewModel 上。同步修改用 setState。一次请求用 Async:Uninitialized、Loading、Success、Fail。execute 把挂起函数的结果写进状态的那一个字段。

CounterMavericks.kt
data class CounterState(
    val count: Int = 0,
    val fact: Async<String> = Uninitialized,
) : MavericksState

class CounterViewModel(
    initialState: CounterState,
) : MavericksViewModel<CounterState>(initialState) {

    fun increment() = setState { copy(count = count + 1) }

    fun loadFact() {
        suspend { NumberApi.fact(awaitState().count) }.execute { copy(fact = it) }
    }
}

execute 进行中时 fact 是 Loading,成功是 Success,失败是 Fail。界面按这个字段画进度和错误,不必再并列一个 loading 布尔和一个 error 字符串。

CounterMavericksScreen.kt
@Composable
fun CounterScreen(viewModel: CounterViewModel = mavericksViewModel()) {
    val state by viewModel.collectAsState()
    Text("${state.count}")
    when (val fact = state.fact) {
        is Success -> Text(fact())
        is Fail -> Text("失败")
        else -> Unit
    }
}

状态默认会保存。进程重建后上一份 CounterState 能回来,因此字段要是 Mavericks 能写入 SavedState 的类型。账单列表这种会变长的数据继续放仓库,状态里留 id 和 Async 的结果摘要。

工程已经是 Mavericks 时,新页面沿用 setState 和 execute。从零开始的 Compose 应用,我用手写的 UiState,请求的加载和失败做成状态里的字段,形状和 Async 一样,只是不引入这套恢复机制。

Molecule

Molecule 让一段 @Composable 函数生产模型,自己不发出任何 UI。launchMolecule 在协程里重组它,输出 StateFlow。LaunchedEffect、remember 因此能写在 ViewModel 这一侧。

MoleculeCounter.kt
data class CounterModel(val count: Int = 0)

sealed interface CounterEvent {
    data object Increment : CounterEvent
}

abstract class MoleculeViewModel<Event, Model> : ViewModel() {
    private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
    private val events = MutableSharedFlow<Event>(extraBufferCapacity = 20)

    val models: StateFlow<Model> by lazy(LazyThreadSafetyMode.NONE) {
        scope.launchMolecule(mode = RecompositionMode.ContextClock) { models(events) }
    }

    fun take(event: Event) {
        events.tryEmit(event)
    }

    @Composable
    protected abstract fun models(events: Flow<Event>): Model
}

class CounterViewModel : MoleculeViewModel<CounterEvent, CounterModel>() {
    @Composable
    override fun models(events: Flow<CounterEvent>): CounterModel {
        var count by remember { mutableIntStateOf(0) }
        LaunchedEffect(Unit) {
            events.collect { event ->
                when (event) {
                    CounterEvent.Increment -> count += 1
                }
            }
        }
        return CounterModel(count)
    }
}

ContextClock 用调用方协程上下文里的帧时钟驱动重组,Android 上把它和 AndroidUiDispatcher.Main 放在一起。界面收集 models,点击调用 take。Presenter 里出现 Text、Button 就越界了:这段组合只为了算出模型。

我在状态依赖彼此取消、分支很多,用 combine 已经难读时,会考虑 Molecule。它和记账应用的边界仍然相同:模型出去,事件进来。差别是计算过程写成组合,而不是写成 combine 的 lambda。

Circuit

Circuit 把一屏拆成 Presenter 和 UI。Presenter 的 present() 是 @Composable,返回状态。事件放在状态里的 eventSink。UI 只调用这个函数,不拿 ViewModel。

CircuitCounter.kt
data class CounterState(
    val count: Int,
    val eventSink: (CounterEvent) -> Unit,
) : CircuitUiState

sealed interface CounterEvent : CircuitUiEvent {
    data object Increment : CounterEvent
}

class CounterPresenter : Presenter<CounterState> {
    @Composable
    override fun present(): CounterState {
        var count by remember { mutableIntStateOf(0) }
        return CounterState(count) { event ->
            when (event) {
                CounterEvent.Increment -> count += 1
            }
        }
    }
}

@Composable
fun Counter(state: CounterState) {
    Button(onClick = { state.eventSink(CounterEvent.Increment) }) {
        Text("${state.count}")
    }
}

present() 标了 presenter 这个组合目标,在里面调用 UI 组合会报警。状态要稳定,事件类型保持不可变。

留存分三档,和这篇开头的表对应:remember 只活过重组;rememberRetained 活过返回栈和旋转;rememberSaveable 再活过进程被杀。导航用 Navigator,可以放在 Presenter 里。Screen 是可保存的键,参数在构造 Presenter 时注进来。

记账应用把导航留在路由表,把一次性离开页面做成 SharedFlow。Circuit 把「去下一页」也看成 Presenter 里的一次调用。页面栈和这一屏的状态要在同一个对象里测时,用它。只想管一屏字段时,它把导航也带进来了。

MVIKotlin

MVIKotlin 的单位是 Store。Intent 进去,State 从 state 出来,一次性的 Label 从 labels 出来。Executor 做副作用,需要改状态时 dispatch 一条消息。Reducer 是纯函数,只根据消息产出新状态。

CounterStore.kt
data class State(val count: Int = 0)

sealed interface Intent {
    data object Increment : Intent
}

sealed interface Msg {
    data object Bump : Msg
}

class CounterExecutor : CoroutineExecutor<Intent, Nothing, State, Msg, Nothing>() {
    override fun executeIntent(intent: Intent) {
        when (intent) {
            Intent.Increment -> dispatch(Msg.Bump)
        }
    }
}

class CounterReducer : Reducer<State, Msg> {
    override fun State.reduce(msg: Msg): State = when (msg) {
        Msg.Bump -> copy(count = count + 1)
    }
}
CounterStoreFactory.kt
val store = storeFactory.create(
    initialState = State(),
    executorFactory = ::CounterExecutor,
    reducer = CounterReducer(),
)
store.accept(Intent.Increment)

界面收集 store.state,点击调用 accept。请求放在 Executor 里,结果再 dispatch 一条消息,由 Reducer 写进状态。要通知界面离开这一页时,用 publish 发一个 Label,作用和 SharedFlow 相同。

库不绑定协程,协程是扩展模块。调试可以打开时间旅行:记录 Intent 和状态,倒回去重放。跨 Android 和 iOS 共用这一块时,Store 放在公共代码里,界面各写各的。导航常用旁边的 Decompose,Store 不负责栈。

我在这块逻辑要脱离 Android、还要能重放一条失败路径时用它。只服务 Android 的一屏,Orbit 或手写的 reduce 更短。

我先按这个选

  • 值只活在一块组合里:remember。旋转或进程重建还要在,而且值能保存:rememberSaveable。
  • 能算出来的值做成计算。派生结果很少变、读它的子组合又很重:derivedStateOf。
  • 这一屏的字段和动作:ViewModel 加 UiState、onEvent。界面用 collectAsStateWithLifecycle()。
  • 可以离开的页面:SharingStarted.WhileSubscribed(5_000)。启动就要、或要提前订上的页面:Eagerly。
  • 保存成功、创建完成:SharedFlow,Route 里收集后导航。
  • 列表和偏好:仓库的 Flow。界面模型在 ViewModel 里组装好。
  • ViewModel 挂在 Activity 上时,会变的路由参数写进 viewModel 的 key。每个入口单独一个 ViewModelStore 时,离开页面用销毁来区分实例。
  • 导航栈和当前标签留在路由那篇。ViewModel 不持有导航器。
  • 老页面已经是 Rx:绑定继续用 RxJava。
  • 想保留 ViewModel,又要把 reduce 和副作用收成可测的形状:Orbit。
  • 工程已经是 Mavericks:继续 setState 和 execute。状态能保存,大列表留在仓库。
  • 状态转移用组合和 LaunchedEffect 更好读:Molecule,输出仍是 StateFlow。
  • 页面栈和 Presenter 要放在一起测:Circuit。
  • 公共代码、时间旅行:MVIKotlin 的 Store。导航另用 Decompose 或现有的返回栈。

参考资料

Copyright © 2026