36 · 状态恢复与进程重建(State Restoration)
一句话定位:移动端的"状态恢复"对应前端的刷新/切后台后页面状态丢失问题,但移动端更狠——系统会在后台杀掉整个进程。本篇讲 Flutter 怎么在进程重建后恢复用户看到的位置、输入与滚动状态。
开篇速览
| 问题 | 答案 |
|---|---|
| 前端对应物 | 浏览器刷新丢状态、SPA 路由状态持久化、localStorage 恢复用户偏好 |
| 核心概念 | RestorationMixin + RestorationScope + 可恢复属性(RestorableProperty) |
| 最常见事故 | App 切后台被杀,回来时白屏/回到首页,用户要重新走流程 |
| 一句话红线 | 默认情况下 Flutter 不做任何状态恢复;不显式接入,进程被杀就丢状态 |
代码来源说明:HaierEnergy 当前
lib/未系统接入RestorationMixin,仅个别页面处理了生命周期回调。 本篇按"通用方案 + 项目接入建议"撰写。
一、先搞清楚"状态为什么丢"
移动端与 Web 最大的不同:系统为了省内存会杀掉后台进程。
| 事件 | 前端(Web) | Flutter(移动端) |
|---|---|---|
| 页面刷新 | 全部内存状态丢失 | 类似,但可恢复框架兜底 |
| 切后台被杀 | 少见(一般 Tab 还活着) | 非常常见,尤其 Android 低内存 |
| 返回上一页 | 路由栈还在 | 路由栈由框架维护,进程没了也全丢 |
| 配置变更(旋转/深色) | 无关 | Activity 重建,也要恢复 |
心智模型:进程被杀 = 一次"硬刷新"。你写的 State 全部消失,SharedPreferences 里的持久化数据还在。
二、Flutter 的恢复三件套
2.1 RestorationScope 注册表
每个可恢复状态都要挂在一个 RestorationScope 下,Flutter 会把恢复数据序列化存起来:
dart
RestorationScope(
restorationId: 'device_detail',
child: const DeviceDetailPage(),
)2.2 页面级 RestorationMixin
dart
class _DeviceDetailPageState extends State<DeviceDetailPage>
with RestorationMixin {
@override
String? get restorationId => 'device_detail';
// 用 Restorable 属性替代普通字段,才能参与恢复
final RestorableInt _selectedTab = RestorableInt(0);
final RestorableStringN _deviceId = RestorableStringN(null);
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_selectedTab, 'selected_tab');
registerForRestoration(_deviceId, 'device_id');
}
@override
void dispose() {
_selectedTab.dispose();
_deviceId.dispose();
super.dispose();
}
}2.3 RestorableProperty 家族
| 类型 | 存的量 | 典型用途 |
|---|---|---|
RestorableInt / RestorableDouble | 数值 | 当前 Tab、滚动位置 |
RestorableString / RestorableStringN | 字符串 | 搜索词、选中设备 ID |
RestorableBool | 布尔 | 开关态 |
RestorableTextEditingController | 输入框 | 输入内容(最常被要求恢复) |
RestorableScrollOffset | 滚动 | 列表回到上次位置 |
RestorableRouteFuture | 路由 | 恢复到"正在打开的子页面" |
三、和 Riverpod 的关系(项目关键)
HaierEnergy 用 Riverpod 管理状态。Riverpod 的 Provider 状态默认存在内存里,进程被杀就没了,它不负责恢复。正确的分层:
| 状态类型 | 放哪 | 恢复方式 |
|---|---|---|
| 纯内存瞬时态(下拉菜单开合) | Riverpod / 局部 State | 不需要恢复 |
| 用户可见关键态(Tab、输入、滚动) | RestorationMixin | 走 RestorationScope |
| 必须跨会话保留的数据(登录态、用户配置) | 本地存储(见 18 篇) | 启动时重新加载 |
| 网络数据 | Provider | 启动后重新拉取(配合缓存) |
推荐组合:
- 登录态/用户信息 →
SharedPreferences/ 安全存储,启动时先读再请求刷新; - 页面内"正在干嘛" →
RestorationMixin恢复; - 网络列表 → 走 Provider 重新请求,可用 12 篇 的分页缓存机制。
四、生命周期钩子:didChangeAppLifecycleState
进程被杀前你还有机会"善后"。用 WidgetsBindingObserver 监听切后台,把关键进度主动持久化:
dart
class AppLifecycleObserver with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
// 切后台:把用户草稿、当前步骤写入本地存储
draftRepository.savePendingDraft();
}
}
}经验:状态恢复不要只依赖框架的自动序列化,对"写了一半的表单"这类高价值数据,切后台时主动落盘一次最可靠。
五、哪些页面值得恢复(优先级)
| 页面类型 | 是否值得 | 原因 |
|---|---|---|
| 长表单/多步流程 | ⭐⭐⭐ | 用户可能填到一半被杀 |
| 详情页/设备页 | ⭐⭐⭐ | 用户要回到"上次看的那台设备" |
| 带 Tab 的列表页 | ⭐⭐ | 恢复选中 Tab + 滚动位置体验提升明显 |
| 首屏/纯静态页 | ⭐ | 恢复成本 > 收益 |
六、测试恢复逻辑
dart
testWidgets('进程重建后恢复选中 Tab', (tester) async {
// 模拟保存恢复数据
final bucket = RestorationBucket.empty(owner: RootRestorationScope.of(tester.element(find.byType(MyApp))));
// 重新 pump 应用并恢复
await tester.pumpWidget(const MyApp(restorationId: 'root'));
await tester.pump();
expect(find.byKey(const Key('tab_2_selected')), findsOneWidget);
});恢复相关的 bug 很隐蔽,建议把"杀进程 → 重启 → 回到原位置"作为发布前冒烟用例。
动手练习
- 给项目里的设备详情页加
RestorationMixin,恢复"选中的 Tab + 设备 ID"; - 给一个多步表单加
RestorableTextEditingController,验证输入内容在重建后还在; - 用
didChangeAppLifecycleState在切后台时把草稿写入本地,重启后提示"继续上次编辑"。
自检清单
- [ ] 能说清"进程被杀 = 硬刷新",与前端刷新丢状态的区别
- [ ] 会用
RestorationMixin+RestorableProperty恢复页面关键态 - [ ] 知道 Riverpod 状态与 Restoration 的分工边界
- [ ] 会评估"哪些页面值得恢复"
- [ ] 知道切后台主动持久化的兜底手段
常见误区
- "Riverpod 能帮我恢复":Provider 状态只在内存,进程被杀就没了;
- "加了 RestorationMixin 就全恢复了":普通字段不会恢复,只有
RestorableProperty会; - "滚动位置无所谓":长列表用户回白屏的挫败感极强,滚动恢复性价比最高;
- "只做 Android":iOS 同样会杀进程,双端都要处理。
相关文档
- 00 · 学习路线图 —— 阅读顺序
- 18 · 本地存储与持久化 —— 跨会话数据的落地方案
- 08 · 状态管理 Riverpod —— 与内存态的分工
- 13 · 路由与导航 —— 路由栈重建后的导航恢复
- 19 · 测试实战 —— 恢复逻辑的 Widget 测试写法