Android ViewModel + LiveData 工程实践:配置变更、一次性事件与状态恢复

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 时,可以先问三个问题:这究竟是页面状态、一次性事件,还是应该持久化的数据?谁有权修改它?过期结果回来后是否还能影响当前页面?

相关推荐
2501_915921432 小时前
iOS应用从开发到上架App Store的完整步骤详解
android·ios·小程序·https·uni-app·iphone·webview
HackTwoHub3 小时前
免 Root 安卓渗透工具 Stryker|把 Kali 装进手机!随身无线渗透工作站,内置 Nmap/Nuclei/Metasploit 全套武器库
android·linux·运维·人工智能·web安全·macos·智能手机
朱涛的自习室13 小时前
70 美金的 Beyond Compare,我让 Gemini 4 和 DeepSeek 造个开源免费版(Day 2)
android·前端·人工智能
.Bryce.17 小时前
在智能体中使用A2A工具:让大模型自己决定“该找谁帮忙“
android·java·服务器·redis·后端·agent·a2a
方舟小孩18 小时前
开源办公软件海豹办公 1.9.15 实战 从模板异常到 Android WebView 资源回归
android
用户21816970493018 小时前
Kotlin 语法四 协程
android
出师牛18 小时前
Android - 再探Handler
android
高晶18 小时前
Android 物联网组网实战:NFC 从零到落地
android·java
stevenzqzq18 小时前
android 维度和渠道
android