4. 交易UI状态管理
交易链路中的购物车、结算页、收银台并不是三个彼此独立的页面,而是同一笔订单在三个不同阶段的三种视图。用户在购物车勾选商品、修改数量,进入结算页选择地址与优惠券,再到收银台选择支付方式并完成支付,这一连串操作背后其实共享着一份不断演进的交易状态。如果每个页面各自维护一套 mutableStateOf 或 LiveData,就会出现典型的“状态漂移”:购物车改了数量,结算页金额没变;结算页选了优惠券,返回购物车再进来优惠又丢了;收银台支付成功后返回,结算页仍停留在待支付。本节要解决的核心问题,就是如何用 ViewModel + StateFlow + Reducer 把这份交易状态收敛成单一可信源,并保证跨页面同步与进程重建后的恢复。
一、为什么交易状态必须收敛为单一可信源
交易状态具有三个显著特征:跨页面共享、多来源修改、需要持久恢复。购物车页、结算页、收银台都可能修改同一份数据,例如数量、优惠券、支付方式。若状态分散在各页面 ViewModel 中,同步只能靠回传、EventBus 或共享 Preferences,链路越长越容易漏。更严重的是,Android 在后台内存紧张时会回收进程,用户回到收银台时页面重建,如果状态只存在内存里,订单信息就会丢失。
因此合理的做法是:把“一笔正在进行的交易”抽象为一个 TradeState,由交易级 ViewModel(作用域覆盖购物车到收银台的导航图)持有,通过 StateFlow 暴露只读状态,所有页面只订阅、不各自持有副本。页面内的临时 UI 状态(如弹窗是否展开、输入框焦点)仍留在各自 ViewModel,避免污染交易状态。
二、StateFlow 作为状态载体
StateFlow 是热流,始终持有当前值,新订阅者立即收到最新状态,天然适合“状态”而非“事件”。交易状态用 StateFlow<TradeState> 暴露,配合 stateIn 或直接在 ViewModel 内 MutableStateFlow 更新。需要注意两点:一是对外只暴露 StateFlow,内部用私有 MutableStateFlow,防止页面直接改状态;二是金额、数量等派生值不要重复存储,用 map 或 combine 从基础状态计算,避免不一致。
data class TradeState(
val items: List<CartItem> = emptyList(),
val coupon: Coupon? = null,
val address: Address? = null,
val payChannel: PayChannel? = null,
val stage: TradeStage = TradeStage.CART
)
val payableAmount: StateFlow<Long> = state
.map { s -> s.items.sumOf { it.price * it.qty } - (s.coupon?.discount ?: 0L) }
.stateIn(viewModelScope, SharingStarted.Eagerly, 0L)
三、Reducer 模式:把状态变更写成纯函数
Reducer 的核心思想是:状态变更 = 旧状态 + 动作 → 新状态,即 (TradeState, TradeAction) -> TradeState。所有修改都通过派发 Action 完成,Reducer 是纯函数,不依赖 Android 框架,因此可单元测试、可回放、可日志追踪。相比在 ViewModel 里散落 _state.value = _state.value.copy(...),Reducer 把“谁能改、怎么改”集中到一处,跨页面同步时只需保证大家派发的是同一套 Action。
sealed interface TradeAction {
data class ChangeQty(val skuId: String, val qty: Int) : TradeAction
data class ApplyCoupon(val coupon: Coupon?) : TradeAction
data class SelectAddress(val address: Address) : TradeAction
data class SelectPayChannel(val channel: PayChannel) : TradeAction
data class MoveToStage(val stage: TradeStage) : TradeAction
}
fun reduce(old: TradeState, action: TradeAction): TradeState = when (action) {
is TradeAction.ChangeQty -> old.copy(items = old.items.map {
if (it.skuId == action.skuId) it.copy(qty = action.qty.coerceIn(1, 99)) else it
})
is TradeAction.ApplyCoupon -> old.copy(coupon = action.coupon)
is TradeAction.SelectAddress -> old.copy(address = action.address)
is TradeAction.SelectPayChannel -> old.copy(payChannel = action.channel)
is TradeAction.MoveToStage -> old.copy(stage = action.stage)
}
ViewModel 只负责把 UI 事件转成 Action 并调用 Reducer:
fun dispatch(action: TradeAction) {
_state.update { reduce(it, action) }
}
四、跨页面同步与状态恢复
三个页面共享同一个交易 ViewModel 后,同步是自动的:购物车派发 ChangeQty,结算页订阅的 payableAmount 立即重算;结算页派发 ApplyCoupon,收银台展示的应付金额同步变化。页面之间不再需要回传结果,只需在导航时切换 stage。
进程重建恢复则依赖 SavedStateHandle。交易状态中真正需要跨进程存活的是“可序列化的关键字段”:商品 SKU 与数量、优惠券 ID、地址 ID、支付渠道、当前 stage。UI 派生值(金额、是否可提交)重建后由 Reducer 重新计算即可,不必保存。实现上可在 ViewModel 初始化时从 SavedStateHandle 读取并构造初始 TradeState,并在每次状态变更后写回关键字段。
private val saved: SavedStateHandle
init {
val restored = saved.get<List<String>>("skuIds")?.let { ids ->
TradeState(items = ids.mapIndexed { i, id ->
CartItem(id, saved.get<Int>("qty_$i") ?: 1, 0L)
})
} ?: TradeState()
_state.value = restored
}
private fun persist(s: TradeState) {
saved["skuIds"] = s.items.map { it.skuId }
s.items.forEachIndexed { i, it -> saved["qty_$i"] = it.qty }
saved["couponId"] = s.coupon?.id
saved["stage"] = s.stage.name
}
五、状态流转与恢复的整体视图
下图把三个页面、共享 ViewModel、Reducer、StateFlow 与 SavedStateHandle 的关系组织在一起:UI 事件统一转为 Action,经 Reducer 生成新状态,StateFlow 广播给所有订阅页面,关键字段同步落盘,进程重建时再回灌为初始状态。
六、实践中的关键判断
第一,状态作用域。交易 ViewModel 应绑定到“购物车→结算→收银台”的嵌套导航图,而不是单个 Fragment,否则页面切换时 ViewModel 被销毁,状态随之丢失。第二,事件与状态分离。支付成功、Toast 提示这类一次性事件用 Channel 或 SharedFlow,不要塞进 StateFlow,否则旋转屏幕会重复触发。第三,Reducer 的边界。Reducer 只做纯计算,网络请求、数据库写入放在 ViewModel 的协程里,请求结果再以 Action 形式回灌,保证 Reducer 可测试。第四,恢复的粒度。只保存用户显式选择的输入,商品价格、库存等易变数据恢复后应重新拉取,避免用旧价格下单。
按上述结构落地后,购物车、结算页、收银台共享同一份 TradeState,任何一页的修改都会通过 StateFlow 同步到其余页面;进程被杀后,SavedStateHandle 中的关键字段重建初始状态,Reducer 重新计算金额与可提交性,用户回到收银台时看到的仍是离开前的那笔订单。状态同步与恢复从“各页面各自为战”变成“一处归约、全局一致”。