SavedStateHandle 实战:让页面状态经得住进程重建
Android 页面从后台返回时,偶尔会出现搜索词丢失、筛选条件复位、编辑内容清空。很多时候这不是普通的配置变更,而是应用进程在后台被系统回收后重新创建。本文从状态边界出发,讲清 ViewModel、SavedStateHandle、rememberSaveable 与持久化存储的职责,并用一个搜索页面完成可恢复、可测试的工程化实现。
先区分三种“页面回来”
看起来都是页面重新显示,背后的生命周期却可能完全不同。
配置变更
旋转屏幕、切换深色模式或改变语言时,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 时,路由参数会进入目标 ViewModel 的 SavedStateHandle。例如商品详情页只需要保存 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 或服务端承担长期数据。
把可恢复条件作为单一事实来源,只保存最小重建线索,并通过进程终止场景验证完整链路,才能让用户从后台回来时真正“接着刚才继续”,而不是面对一个看似熟悉却已被清空的页面。