SavedStateHandle 实战:让页面状态经得住进程重建

简介: 本文详解 `SavedStateHandle` 在进程重建场景下的工程化应用:厘清 ViewModel、SavedStateHandle、rememberSaveable 与持久化存储的职责边界;以搜索页为例,演示如何仅保存关键词、筛选条件等“最小重建线索”,恢复后重新加载数据,避免状态丢失与 Bundle 膨胀;涵盖测试、避坑与落地检查清单。

SavedStateHandle 实战:让页面状态经得住进程重建

Android 页面从后台返回时,偶尔会出现搜索词丢失、筛选条件复位、编辑内容清空。很多时候这不是普通的配置变更,而是应用进程在后台被系统回收后重新创建。本文从状态边界出发,讲清 ViewModelSavedStateHandlerememberSaveable 与持久化存储的职责,并用一个搜索页面完成可恢复、可测试的工程化实现。

先区分三种“页面回来”

看起来都是页面重新显示,背后的生命周期却可能完全不同。

配置变更

旋转屏幕、切换深色模式或改变语言时,Activity 通常会被重建,但应用进程仍然存在。ViewModel 能跨越这类重建,因此页面状态通常不会丢失。

进程被系统回收

应用进入后台后,系统可能为了释放内存而终止进程。用户从最近任务返回时,系统会尝试恢复导航栈和组件状态,但原来的 ViewModel 已经不存在。仅保存在普通字段、StateFlow 或内存缓存里的数据都会消失。

用户主动结束任务

用户从最近任务划掉应用、强行停止应用,或业务主动退出登录,语义上通常代表一次新的会话。不要把所有旧状态都无条件恢复,否则容易把过期页面和敏感信息带回来。

所以,状态恢复的核心不是“尽量多存”,而是先回答:什么状态值得恢复,恢复到什么时候。

Android 状态存储的职责边界

可以把常见方案理解成不同耐久级别:

  • 普通变量、StateFlow:适合当前进程中的运行时状态。
  • ViewModel:适合跨配置变更,但不能独自应对进程死亡。
  • SavedStateHandle:适合体积小、可序列化、与当前页面直接相关的临时状态。
  • rememberSaveable:适合 Compose 局部 UI 状态,例如折叠开关、当前输入框内容。
  • DataStore、Room、文件:适合需要跨会话长期保留的数据。
  • 服务端:适合多端共享、可同步或权威业务数据。

SavedStateHandle 不是数据库。它最终依赖系统保存的状态 Bundle,容量和类型都有限。大列表、Bitmap、复杂领域对象不应该直接塞进去。更稳妥的做法是保存 ID、查询条件、页签位置等“重建线索”,然后从仓库重新加载真实数据。

一个容易出问题的搜索页面

假设页面包含这些状态:

  • 搜索关键词;
  • 排序方式;
  • 是否只看有库存商品;
  • 搜索结果;
  • 加载状态与错误提示。

其中,关键词和筛选条件是恢复页面所需的最小输入,适合保存。搜索结果来自网络或数据库,应该在页面恢复后重新查询。加载中、错误提示属于瞬时状态,不应该原样复活。

先定义可保存的筛选条件:

enum class SortMode {
    RELEVANCE,
    PRICE_ASC,
    PRICE_DESC
}

data class SearchCriteria(
    val keyword: String = "",
    val sortMode: SortMode = SortMode.RELEVANCE,
    val inStockOnly: Boolean = false
)

如果领域对象结构复杂或未来可能频繁变化,可以拆成多个基础字段保存,降低序列化和版本兼容成本。

用 SavedStateHandle 驱动可恢复状态

推荐让 SavedStateHandle 成为可恢复输入的单一事实来源,再通过 StateFlow 组合查询条件。

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val repository: ProductRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private companion object {
        const val KEYWORD = "search.keyword"
        const val SORT_MODE = "search.sort_mode"
        const val IN_STOCK_ONLY = "search.in_stock_only"
    }

    private val keyword = savedStateHandle.getStateFlow(KEYWORD, "")
    private val sortMode = savedStateHandle.getStateFlow(
        SORT_MODE,
        SortMode.RELEVANCE.name
    )
    private val inStockOnly = savedStateHandle.getStateFlow(
        IN_STOCK_ONLY,
        false
    )

    private val criteria = combine(
        keyword,
        sortMode,
        inStockOnly
    ) { query, sortName, onlyInStock ->
        SearchCriteria(
            keyword = query,
            sortMode = runCatching { SortMode.valueOf(sortName) }
                .getOrDefault(SortMode.RELEVANCE),
            inStockOnly = onlyInStock
        )
    }.distinctUntilChanged()

    val uiState: StateFlow<SearchUiState> = criteria
        .debounce(300)
        .flatMapLatest { value ->
            if (value.keyword.isBlank()) {
                flowOf(SearchUiState.Empty)
            } else {
                repository.search(value)
                    .map<List<Product>, SearchUiState> {
                        SearchUiState.Content(it)
                    }
                    .onStart { emit(SearchUiState.Loading) }
                    .catch { emit(SearchUiState.Error(it.toUserMessage())) }
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = SearchUiState.Empty
        )

    fun updateKeyword(value: String) {
        savedStateHandle[KEYWORD] = value
    }

    fun updateSortMode(value: SortMode) {
        savedStateHandle[SORT_MODE] = value.name
    }

    fun updateInStockOnly(value: Boolean) {
        savedStateHandle[IN_STOCK_ONLY] = value
    }
}

这里有几个关键点:

  • 更新入口直接写入 SavedStateHandle,避免另建一份可恢复状态后忘记同步。
  • 枚举保存为稳定字符串,读取时提供兜底值,防止升级后枚举项变化导致崩溃。
  • flatMapLatest 会取消旧查询,适合关键词连续变化的场景。
  • 搜索结果不保存,恢复后根据条件重新加载,避免 Bundle 膨胀和数据过期。

Compose 页面如何接入

页面只订阅 uiState,并把用户操作交还给 ViewModel

@Composable
fun SearchRoute(
    viewModel: SearchViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val keyword by viewModel.keywordState.collectAsStateWithLifecycle()

    SearchScreen(
        keyword = keyword,
        uiState = uiState,
        onKeywordChange = viewModel::updateKeyword,
        onSortChange = viewModel::updateSortMode,
        onStockFilterChange = viewModel::updateInStockOnly
    )
}

为了让示例可调用,可以在 ViewModel 中暴露只读状态:

val keywordState: StateFlow<String> = keyword

不要再用 remember { mutableStateOf(...) } 复制一份关键词。双份状态会带来初始化覆盖、回写循环和恢复时序问题。如果输入框需要局部编辑缓冲,例如输入结束后才提交,可以使用 rememberSaveable 暂存,并明确提交时机。

Navigation 参数与 SavedStateHandle

使用 Navigation Component 时,路由参数会进入目标 ViewModelSavedStateHandle。例如商品详情页只需要保存 productId

@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    repository: ProductRepository
) : ViewModel() {

    private val productId: Long = checkNotNull(savedStateHandle["productId"])

    val uiState = repository.observeProduct(productId)
        .map { product -> ProductDetailUiState.Content(product) }
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            ProductDetailUiState.Loading
        )
}

这种“保存主键、重查数据”的方式比保存整个商品对象稳定得多。它还能让页面自然获得最新数据。

一次性事件不要当作可恢复状态

Toast、Snackbar、跳转指令、支付结果弹窗通常只应消费一次。如果把它们作为普通字段保存到 SavedStateHandle,进程重建后可能再次触发。

更合适的做法是区分两类信息:

  • 业务事实:例如“订单已支付”,应该由仓库或服务端状态表达。
  • 展示事件:例如“显示支付成功 Snackbar”,使用事件流并在消费后结束。

如果某个流程必须跨进程继续,保存流程阶段或业务 ID,而不是保存“弹窗待显示”这样的 UI 命令。

控制 Bundle 大小与类型

SavedStateHandle 支持的值最终需要能被系统状态机制保存。工程中应遵守这些约束:

  • 优先保存 String、数字、布尔值和小型数组。
  • Parcelable 只用于小对象,并注意字段升级兼容。
  • 不保存列表快照、图片二进制、网络响应或数据库实体集合。
  • 不保存密码、令牌、身份证号等敏感信息。
  • Key 集中定义并保持稳定,避免重构时无意改名。

当保存内容过大时,系统可能抛出 TransactionTooLargeException,而且问题往往只在线上特定页面栈中出现。保存“重建页面所需的最小信息”是最有效的预防方式。

正确测试进程重建

只旋转屏幕只能验证配置变更,无法证明进程恢复正确。可以用开发者选项中的“不保留活动”辅助发现问题,但它与真实的进程回收仍有差异。

更可靠的手工验证流程是:

  • 打开目标页面并输入关键词、修改筛选条件;
  • 按 Home 键让应用进入后台;
  • 使用 Android Studio 的终止应用进程能力,或在调试环境执行 adb shell am kill <package>
  • 从最近任务返回应用;
  • 检查导航位置、输入条件与业务数据是否按设计恢复。

不要使用“强行停止”替代这项测试。强行停止会改变应用的启动语义,也可能清除最近任务或阻止部分后台行为。

给 ViewModel 写恢复测试

SavedStateHandle 可以直接在单元测试中构造,适合验证恢复输入是否会驱动正确查询:

@Test
fun restoredCriteria_triggerSearchWithSavedValues() = runTest {
    val handle = SavedStateHandle(
        mapOf(
            "search.keyword" to "camera",
            "search.sort_mode" to SortMode.PRICE_ASC.name,
            "search.in_stock_only" to true
        )
    )
    val repository = FakeProductRepository()

    val viewModel = SearchViewModel(repository, handle)
    viewModel.uiState.test {
        assertEquals(SearchUiState.Empty, awaitItem())
        assertEquals(SearchUiState.Loading, awaitItem())
        assertTrue(awaitItem() is SearchUiState.Content)
    }

    assertEquals(
        SearchCriteria("camera", SortMode.PRICE_ASC, true),
        repository.lastCriteria
    )
}

还应覆盖非法枚举值、空关键词、仓库异常和快速连续输入。测试目标不是证明框架能存值,而是证明恢复后的值能正确驱动业务链路。

常见误区与修正

只要用了 ViewModel 就不会丢状态

ViewModel 只保证跨配置变更保留实例。进程结束后它会重新创建。需要恢复的页面输入应进入 SavedStateHandle 或更耐久的存储。

把整个 UiState 都保存下来

UiState 往往包含大列表、加载状态和瞬时错误。整体保存既浪费空间,也会恢复过期结果。应拆出关键词、ID、选项等最小输入。

启动时先写默认值

如果初始化逻辑无条件执行 savedStateHandle[key] = defaultValue,系统恢复的旧值会被立即覆盖。默认值应放在 getStateFlow(key, defaultValue) 等读取入口,而不是每次启动都回写。

UI 与 ViewModel 各维护一份状态

两边同步很容易形成竞态。优先坚持单向数据流:状态从 ViewModel 到 UI,事件从 UI 回到 ViewModel

所有状态都长期持久化

长期存储会引入过期、迁移、隐私和清理成本。临时筛选条件未必值得跨用户会话保留。先定义产品语义,再选择存储层级。

一套可落地的检查清单

在代码评审时,可以逐项确认:

  • 页面恢复所需的最小输入是什么;
  • 哪些状态只需跨重组,哪些需跨配置变更或进程重建;
  • 大对象是否改为保存 ID 并重新加载;
  • 默认值是否会覆盖系统恢复值;
  • 一次性事件是否可能重复消费;
  • Key 与序列化格式是否稳定且有兜底;
  • 是否验证过真实进程重建,而不只是旋转屏幕;
  • 是否避免保存敏感数据和过大内容。

总结

可靠的状态恢复依赖清晰的边界,而不是某个万能 API。ViewModel 管理当前页面的运行时逻辑,SavedStateHandle 保存重建页面所需的小型临时输入,rememberSaveable 处理 Compose 局部状态,Room、DataStore 或服务端承担长期数据。

把可恢复条件作为单一事实来源,只保存最小重建线索,并通过进程终止场景验证完整链路,才能让用户从后台回来时真正“接着刚才继续”,而不是面对一个看似熟悉却已被清空的页面。

相关文章
|
7天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2043 11
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
7天前
|
云安全 人工智能 安全
|
7天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max-Preview深度全解析:2.4万亿参数旗舰MoE模型+Token Plan限时优惠完整落地指南
2026年7月,全新旗舰级混合专家大模型Qwen3.8-Max-Preview正式开放抢先体验,作为通义千问Qwen3系列规格最高、综合推理能力顶尖的新一代模型,该模型总参数量达到2.4万亿(2.4T),是当前线上可调用的原生多模态旗舰模型,综合推理水准对标海外顶级Fable 5模型,在复杂工程开发、长文档深度分析、多步骤智能体自治、跨境多语言创作、海量数据挖掘五大高难度业务场景实现跨越式性能提升。
903 1
|
7天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
911 0
|
9天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
912 39
|
5天前
|
自然语言处理 测试技术 API
通义千问Qwen3.8-Max-Preview全功能解析:2.4万亿参数旗舰模型深度使用指南
在大模型技术持续迭代的当下,通义千问推出的Qwen3.8-Max-Preview作为新一代旗舰预览版模型,凭借2.4万亿参数的超大规模、多模态融合能力与全场景适配特性,成为开发者与企业用户探索AI应用的核心工具。该模型采用稀疏混合专家(MoE)架构,是通义千问首个突破万亿参数的多模态模型,可同时处理文本、图像、视频与文档等多种数据形态,在全栈代码开发、复杂逻辑推理、长文档分析与多智能体协作等场景实现跨越式升级。本文将全面拆解Qwen3.8-Max-Preview的核心功能,详解API调用流程与配置方法,覆盖多场景实战技巧,帮助用户快速掌握这款旗舰模型的使用方法,充分释放其性能潜力。
444 1
|
8天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
669 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南