Android ViewModel + LiveData 工程实践:配置变更、一次性事件与状态恢复
摘要:ViewModel 和 LiveData 经常被当成"把数据从 Activity 挪出去"的简单工具,但真实项目更容易在配置变更、重复事件、异步竞态和进程重建时出问题。本文以搜索页面为例,设计不可变 UI 状态、一次性事件和请求代际控制,说明 XML + Fragment 项目如何把页面状态管理得更稳定。
页面状态真正难在哪里
一个列表页面通常同时面对首次加载、刷新、加载更多、空数据、错误、筛选条件、搜索关键字和用户点击后的导航事件。如果把这些状态分别放进多个 LiveData,页面可能出现互相矛盾的组合:加载结束但列表仍是旧数据,错误提示没有清空,或者旋转屏幕后重复弹出 Toast。
ViewModel 的价值不是为 Activity 保存几个变量,而是建立稳定的状态边界:
- UI 只负责渲染状态和转发用户操作。
- ViewModel 负责把用户操作转换成状态变化。
- Repository 负责网络、数据库和缓存细节。
- 一次性事件和可恢复的页面状态分开建模。
用一个 UI 状态承载页面事实
不要为列表、加载中、错误信息分别声明多个公开的可变数据源。可以用一个不可变数据类描述页面在某一时刻的完整事实:
kotlin
data class SearchUiState(
val query: String = "",
val items: List<Article> = emptyList(),
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val nextPage: Int = 1,
val hasMore: Boolean = true,
val errorMessage: String? = null
)
ViewModel 内部只持有一个 MutableLiveData,对外暴露只读类型:
kotlin
class SearchViewModel(
private val repository: ArticleRepository
) : ViewModel() {
private val _uiState = MutableLiveData(SearchUiState())
val uiState: LiveData<SearchUiState> = _uiState
fun updateQuery(value: String) {
val old = _uiState.value ?: SearchUiState()
_uiState.value = old.copy(query = value, errorMessage = null)
}
}
copy() 让状态变化显式可见,也避免页面直接修改列表对象导致观察者无法感知变化。
处理异步请求和过期结果
搜索框快速输入时,旧请求可能比新请求更晚返回。如果直接把每个请求的结果写入 LiveData,就可能出现用户已经搜索 kotlin,页面却被前一个 ko 请求覆盖的情况。
在协程项目里,可以使用 viewModelScope 和请求代际标记:
kotlin
private var searchGeneration = 0
fun search() {
val query = _uiState.value?.query?.trim().orEmpty()
if (query.isEmpty()) return
val generation = ++searchGeneration
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
runCatching { repository.search(query) }
.onSuccess { result ->
if (generation != searchGeneration) return@onSuccess
_uiState.update {
it.copy(
items = result,
isRefreshing = false,
nextPage = 2,
hasMore = result.isNotEmpty()
)
}
}
.onFailure { error ->
if (generation != searchGeneration) return@onFailure
_uiState.update {
it.copy(isRefreshing = false, errorMessage = error.message)
}
}
}
}
更复杂的输入流可以使用 debounce、distinctUntilChanged 和 flatMapLatest,让新关键字自动取消旧请求。核心原则是:过期结果不能再修改当前页面状态。
刷新和加载更多要区分
isLoading 一个字段无法表达所有列表状态。刷新通常保留当前列表并显示顶部进度条,首次加载可能显示全屏占位,加载更多则应该保留已有内容并在列表底部显示进度。
因此状态里至少要区分 isRefreshing 和 isLoadingMore:
kotlin
fun loadMore() {
val state = _uiState.value ?: return
if (state.isLoadingMore || !state.hasMore || state.query.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoadingMore = true) }
runCatching {
repository.search(state.query, page = state.nextPage)
}.onSuccess { result ->
_uiState.update {
it.copy(
items = it.items + result,
nextPage = it.nextPage + 1,
hasMore = result.isNotEmpty(),
isLoadingMore = false
)
}
}.onFailure { error ->
_uiState.update {
it.copy(isLoadingMore = false, errorMessage = error.message)
}
}
}
}
读取操作开始前要重新读取当前状态,不能长时间持有旧的 state。如果多个入口都可能触发加载,还应在 Repository 或 ViewModel 中保证同一页不会并发请求。
一次性事件不要伪装成页面状态
列表内容、加载状态和筛选条件会在页面重建后继续存在,它们是可恢复状态。Toast、Snackbar、导航和打开弹窗只应该消费一次,它们是事件。把两者都塞进普通 LiveData,会导致旋转屏幕后再次收到旧事件。
一种直接的做法是定义事件包装器:
kotlin
class Event<out T>(private val content: T) {
private var handled = false
fun getContentIfNotHandled(): T? {
if (handled) return null
handled = true
return content
}
}
private val _event = MutableLiveData<Event<UiEvent>>()
val event: LiveData<Event<UiEvent>> = _event
sealed interface UiEvent {
data class ShowMessage(val text: String) : UiEvent
data class OpenDetail(val id: Long) : UiEvent
}
Fragment 中只在拿到未消费内容时执行动作:
kotlin
viewModel.event.observe(viewLifecycleOwner) { event ->
when (val action = event.getContentIfNotHandled()) {
is UiEvent.ShowMessage -> showSnackbar(action.text)
is UiEvent.OpenDetail -> findNavController()
.navigate(SearchFragmentDirections.openDetail(action.id))
null -> Unit
}
}
事件包装器适合逐步改造旧项目,但它仍然是内存状态。进程被系统杀死后,未消费的事件不会自动恢复。需要恢复的动作,应转换成可持久化的页面状态。
Fragment 中观察 LiveData 的生命周期
Activity 的生命周期和 Fragment 的 View 生命周期不是一回事。Fragment 仍在返回栈中时,它的 View 可能已经销毁,因此应该使用 viewLifecycleOwner:
kotlin
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.uiState.observe(viewLifecycleOwner) { state ->
render(state)
}
viewModel.event.observe(viewLifecycleOwner) { event ->
handleEvent(event)
}
}
不要在 onCreate() 里用 Fragment 自己作为观察者再直接更新 View,避免回调访问已经失效的控件。
配置变更不等于进程恢复
ViewModel 能跨越旋转、分屏和夜间模式切换等配置变更,但不能保证进程被系统杀死后仍存在。需要区分三种数据:
- 只用于当前渲染的数据,可以保存在 ViewModel 的 UI 状态中。
- 重建页面必须保留的小参数,例如筛选条件、对象 ID,可以使用
SavedStateHandle。 - 需要长期保留的数据,例如草稿、历史记录和缓存,应写入 DataStore、Room 或服务端。
带 SavedStateHandle 的 ViewModel 可以保存搜索关键字:
kotlin
class SearchViewModel(
private val savedState: SavedStateHandle,
private val repository: ArticleRepository
) : ViewModel() {
private val query = savedState.getLiveData("query", "")
fun updateQuery(value: String) {
savedState["query"] = value
}
}
SavedStateHandle 适合小型、可序列化的输入,不适合保存大列表或复杂缓存。数据真正需要跨进程恢复时,仍应以持久化存储为准,ViewModel 只负责重新加载和组合状态。
不要让 ViewModel 变成业务垃圾桶
ViewModel 可以协调页面状态,但不应该直接承担所有业务。推荐保持这样的依赖方向:
text
Fragment -> ViewModel -> UseCase -> Repository -> 数据源
ViewModel 不应持有 Activity、Fragment、View 或 Adapter。需要展示字符串资源时,可以让 UI 层根据错误类型映射文案,或者使用不依赖 View 的资源访问接口。需要 Context 时优先使用 Application Context,并确认依赖确实属于数据层或系统能力。
还要避免把 MutableLiveData、线程切换和异常吞掉的逻辑散落到 Repository。Repository 应该返回明确的结果类型,ViewModel 再决定页面如何展示成功、空数据和失败。
测试状态转换,而不是只点页面
ViewModel 的测试重点不是 Android 控件,而是用户操作是否产生正确状态。至少覆盖这些场景:
- 输入关键字后触发搜索,成功结果替换列表。
- 空结果显示空状态而不是错误状态。
- 请求失败后能重试,旧列表是否按产品要求保留。
- 快速输入时旧请求结果不会覆盖新结果。
- 加载更多时不会重复请求同一页。
- 一次性事件只被消费一次。
如果状态模型清晰,测试可以在不启动 Activity 的情况下覆盖大量边界。UI 自动化测试则保留给导航、权限和真正的渲染行为。
总结
ViewModel + LiveData 的关键不在于把字段换个地方存储,而在于把页面事实、一次性事件和持久化数据分开。用一个不可变 UI 状态描述渲染事实,用事件流承载导航和提示,用请求代际控制过期结果,再用 SavedStateHandle 和数据库覆盖不同级别的恢复需求,页面才不会被配置变更和异步竞态拖垮。
当一个页面开始出现多个互相影响的 LiveData 时,可以先问三个问题:这究竟是页面状态、一次性事件,还是应该持久化的数据?谁有权修改它?过期结果回来后是否还能影响当前页面?