Android 页面从后台返回时,偶尔会出现搜索词丢失、筛选条件复位、编辑内容清空。很多时候这不是普通的配置变更,而是应用进程在后台被系统回收后重新创建。本文从状态边界出发,讲清 ViewModel、SavedStateHandle、rememberSaveable 与持久化存储的职责,并用一个搜索页面完成可恢复、可测试的工程化实现。
看起来都是页面重新显示,背后的生命周期却可能完全不同。
旋转屏幕、切换深色模式或改变语言时,Activity 通常会被重建,但应用进程仍然存在。ViewModel 能跨越这类重建,因此页面状态通常不会丢失。
应用进入后台后,系统可能为了释放内存而终止进程。用户从最近任务返回时,系统会尝试恢复导航栈和组件状态,但原来的 ViewModel 已经不存在。仅保存在普通字段、StateFlow 或内存缓存里的数据都会消失。
用户从最近任务划掉应用、强行停止应用,或业务主动退出登录,语义上通常代表一次新的会话。不要把所有旧状态都无条件恢复,否则容易把过期页面和敏感信息带回来。
所以,状态恢复的核心不是“尽量多存”,而是先回答:什么状态值得恢复,恢复到什么时候。
可以把常见方案理解成不同耐久级别:
StateFlow:适合当前进程中的运行时状态。ViewModel:适合跨配置变更,但不能独自应对进程死亡。SavedStateHandle:适合体积小、可序列化、与当前页面直接相关的临时状态。rememberSaveable:适合 Compose 局部 UI 状态,例如折叠开关、当前输入框内容。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 成为可恢复输入的单一事实来源,再通过 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 会取消旧查询,适合关键词连续变化的场景。页面只订阅 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 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,进程重建后可能再次触发。
更合适的做法是区分两类信息:
如果某个流程必须跨进程继续,保存流程阶段或业务 ID,而不是保存“弹窗待显示”这样的 UI 命令。
SavedStateHandle 支持的值最终需要能被系统状态机制保存。工程中应遵守这些约束:
String、数字、布尔值和小型数组。Parcelable 只用于小对象,并注意字段升级兼容。当保存内容过大时,系统可能抛出 TransactionTooLargeException,而且问题往往只在线上特定页面栈中出现。保存“重建页面所需的最小信息”是最有效的预防方式。
只旋转屏幕只能验证配置变更,无法证明进程恢复正确。可以用开发者选项中的“不保留活动”辅助发现问题,但它与真实的进程回收仍有差异。
更可靠的手工验证流程是:
adb shell am kill <package>;不要使用“强行停止”替代这项测试。强行停止会改变应用的启动语义,也可能清除最近任务或阻止部分后台行为。
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 只保证跨配置变更保留实例。进程结束后它会重新创建。需要恢复的页面输入应进入 SavedStateHandle 或更耐久的存储。
UiState 往往包含大列表、加载状态和瞬时错误。整体保存既浪费空间,也会恢复过期结果。应拆出关键词、ID、选项等最小输入。
如果初始化逻辑无条件执行 savedStateHandle[key] = defaultValue,系统恢复的旧值会被立即覆盖。默认值应放在 getStateFlow(key, defaultValue) 等读取入口,而不是每次启动都回写。
两边同步很容易形成竞态。优先坚持单向数据流:状态从 ViewModel 到 UI,事件从 UI 回到 ViewModel。
长期存储会引入过期、迁移、隐私和清理成本。临时筛选条件未必值得跨用户会话保留。先定义产品语义,再选择存储层级。
在代码评审时,可以逐项确认:
可靠的状态恢复依赖清晰的边界,而不是某个万能 API。ViewModel 管理当前页面的运行时逻辑,SavedStateHandle 保存重建页面所需的小型临时输入,rememberSaveable 处理 Compose 局部状态,Room、DataStore 或服务端承担长期数据。
把可恢复条件作为单一事实来源,只保存最小重建线索,并通过进程终止场景验证完整链路,才能让用户从后台回来时真正“接着刚才继续”,而不是面对一个看似熟悉却已被清空的页面。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。