首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >SavedStateHandle 实战:让页面状态经得住进程重建

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

原创
作者头像
hunter android
发布2026-07-28 10:23:14
发布2026-07-28 10:23:14
410
举报

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

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

先区分三种“页面回来”

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

配置变更

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

进程被系统回收

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

用户主动结束任务

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

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

Android 状态存储的职责边界

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

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

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

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

假设页面包含这些状态:

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

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

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

代码语言:javascript
复制
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 组合查询条件。

代码语言:javascript
复制
@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

代码语言:javascript
复制
@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 中暴露只读状态:

代码语言:javascript
复制
val keywordState: StateFlow<String> = keyword

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

Navigation 参数与 SavedStateHandle

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

代码语言:javascript
复制
@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 可以直接在单元测试中构造,适合验证恢复输入是否会驱动正确查询:

代码语言:javascript
复制
@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 或服务端承担长期数据。

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • SavedStateHandle 实战:让页面状态经得住进程重建
    • 先区分三种“页面回来”
      • 配置变更
      • 进程被系统回收
      • 用户主动结束任务
    • Android 状态存储的职责边界
    • 一个容易出问题的搜索页面
    • 用 SavedStateHandle 驱动可恢复状态
    • Compose 页面如何接入
    • Navigation 参数与 SavedStateHandle
    • 一次性事件不要当作可恢复状态
    • 控制 Bundle 大小与类型
    • 正确测试进程重建
    • 给 ViewModel 写恢复测试
    • 常见误区与修正
      • 只要用了 ViewModel 就不会丢状态
      • 把整个 UiState 都保存下来
      • 启动时先写默认值
      • UI 与 ViewModel 各维护一份状态
      • 所有状态都长期持久化
    • 一套可落地的检查清单
    • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档