我把 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 里的 StateFlow | ViewModelStore | 还在 | 重新创建,初值重新来 |
| 仓库里的 Flow | 进程内单例 | 还在 | 从数据库或 DataStore 再读 |
返回栈上的 NavKey | rememberNavBackStack | 还在 | 键能序列化就还在 |
组合里的值
一个对话框开没开、一次还没提交的输入,先放在调用它的组合里。remember 把存储留在这块组合的身份上。组合函数再次执行时,拿到的是上次的值。
@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 的字段。
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 这样订阅:
@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 或关掉对话框。
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)
}
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 |
| Orbit | ViewModel 上的 intent / reduce | postSideEffect | 最接近现在这套,样板收进容器 |
| Mavericks | 不可变 State,setState,Async | 状态里的 Async.Fail,或自己的事件 | Airbnb 系。状态默认可恢复 |
| Molecule | @Composable 算出模型,输出 StateFlow | 模型里的字段,或另开一条流 | 逻辑想用 LaunchedEffect 写 |
| Circuit | present() 返回带 eventSink 的状态 | 状态里的字段;导航走 Presenter 的 Navigator | 状态和页面栈要同一套时 |
| MVIKotlin | Store:Intent 进,State 出 | Label | 要时间旅行、要跨端时。常和 Decompose 一起用 |
同一类形状还有 Ballast、FlowRedux。动作进、状态出、副作用在旁边。下面六种够对照。
RxJava
Observable 表示以后还会来的值。BehaviorRelay 记住当前值,后来的订阅者马上拿到它。订阅放进 CompositeDisposable,控制器或收集方释放时一起取消。
class RxCounterViewModel : ViewModel() {
val count = BehaviorRelay.createDefault(0)
fun increment() {
count.accept(count.value!! + 1)
}
override fun onCleared() {
// Relay 本身不持有界面。订阅在收集那一侧 dispose。
}
}
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。
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 跟着组合的生命周期,页面停在后台时不再接收。
@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 把挂起函数的结果写进状态的那一个字段。
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 字符串。
@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 这一侧。
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。
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 是纯函数,只根据消息产出新状态。
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)
}
}
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 或现有的返回栈。
参考资料
- Compose 中的状态:
remember、状态提升、derivedStateOf - ViewModel:作用域和
viewModel() - StateFlow 和 SharedFlow:当前值和一次性事件
- Orbit:
intent、reduce、postSideEffect - Mavericks:
setState和Async - Molecule:
launchMolecule把组合变成StateFlow - Circuit:Presenter、
eventSink、rememberRetained - MVIKotlin:Store、Executor、Reducer、Label