Android 可以在后台杀掉进程。用户回来时期望滚动位置、表单和导航栈还在。State 活在 Dart 堆上,进程死了堆就没了。状态恢复的原理不是「缓存一下」,而是:在引擎被杀前,把一棵可序列化的恢复树交给操作系统;新进程里新 Engine 冷启动,再按 key 灌回 Element。 它和 KeepAlive、和内存里的 static、和磁盘数据库,是三种寿命完全不同的机制。

读完你应该能独立回答三个问题:restoreState 相对 initState 何时发生、哪些状态绝对不该进 bucket、多 Engine 时 restoration id 为什么不能撞。

1. 先画边界:三种「还在」

机制活过什么不活过什么典型用途
State / 内存重建 Widget进程死、Engine destroy当前会话
AutomaticKeepAlive滑出视口进程死、路由 popTab / PageView 保状态
Restoration bucket进程被杀用户明确退出、卸载、你自己没注册的字段滚动、表单、路由栈
磁盘 / 服务端重装以外几乎都能用户清数据登录态、草稿内容本身

Restoration 解决的是「系统以为你还在后台,其实进程没了」的缝。它不解决「用户昨天填到一半」——那是草稿持久化,该进你自己的存储,带版本和加密。把整个 bloc 塞进 restoration 会撑爆 savedInstanceState 的预算(Android 上几到十几 MB 量级且要快)。

不变量:

  • 恢复数据是 值,可编码,按字符串 key 成树。
  • 新进程是新 Isolate、新 static、新插件实例。只有 bucket 里的东西会回来。
  • 第一帧 可以 已经是恢复后的 UI,条件是 restoreState 发生在第一帧 build 之前。
  • 没有 restorationId 的子树,整棵不参与。id 是自愿加入。

iOS 对后台杀进程比 Android 保守,但这套 API 两端都在。不要写成 if (Platform.isAndroid) 才开——开的成本很小,关的是 Android 上真实的差评。

2. 恢复树:Bucket 的形状

RestorationManager 持有根 RestorationBucket。RestorationScope 按 restorationId 开子 bucket,形成和 Widget 树 相似但不等于 的一棵树。id 在兄弟间必须唯一,否则覆盖或断言。

RestorationMixin 在 State 里:

class _FormState extends State<OrderForm> with RestorationMixin {
  final RestorableTextEditingController name = RestorableTextEditingController();
  final RestorableInt step = RestorableInt(0);

  @override
  String? get restorationId => widget.restorationId;

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(name, 'name');
    registerForRestoration(step, 'step');
  }

  @override
  void dispose() {
    name.dispose();
    step.dispose();
    super.dispose();
  }
}

registerForRestoration 把属性挂到当前 bucket 的某个 key 下。RestorableProperty 知道如何 fromPrimitives / toPrimitives。框架提供了 int、string、bool、text controller、scroll offset、page 控制器等。自定义对象要自己写 RestorableValue,只返回 codec 认识的 primitive(和 StandardMessageCodec 那套类似:num、string、list、map)。

oldBucket != null && !initialRestore 表示 scope id 变了,状态从旧桶迁到新桶。initialRestore == true 表示进程恢复。不要在 restoreState 里做网络请求。

3. 和 Engine 生命周期的时序

一次 Android 进程被杀再回来,粗时序:

  1. 系统先调 onSaveInstanceState。Embedder 向 Dart 要当前 bucket 的序列化字节,写入 Bundle。
  2. 进程没了。Dart 堆、GPU 纹理、Channel 注册表全没了。
  3. 用户回来。新进程、新 FlutterEngine(或 Group spawn 的新 Isolate)。
  4. Embedder 把 Bundle 里的字节交给 RestorationManager。
  5. runApp → 第一轮 initState → restoreState → 第一帧 build。
  6. 若 WidgetsApp.restorationScopeId 和 Navigator 的 restoration 开了,路由栈按 id 重建,深页直接出现,而不是闪首页再 push。

关键:restoreState 在 initState 之后、第一帧之前。在 initState 里读 name.value 可能还是默认值;在 build 里已经是恢复值。把「只做一次」的逻辑放 initState 且依赖恢复值,会做错。放 restoreState 或第一帧后的 WidgetsBinding.instance.addPostFrameCallback 要分清是不是只在 initialRestore 时跑。

AppLifecycleState:

  • resumed / inactive / hidden / paused:进程可能还在,bucket 不必写盘,但 paused 是系统可能随后杀进程的信号。框架会在合适的时机序列化;你要保证此时 Restorable 属性已经是最新值(滚动 offset 通常由 Scrollable 自己写)。
  • detached:Engine 与视图分离,即将销毁。未注册的内存状态不会有人救。

不要自己在 paused 里 jsonEncode 整个 AppState 再走 Channel 存 SharedPreferences 当作 restoration——那是另一套草稿机制,时序、大小、主线程都会打架。

4. Navigator、滚动、表单:框架已经接好的

  • WidgetsApp.restorationScopeId: 总开关。没有它,下面全空转。
  • Navigator restoration: 每个 Route 要有 id,且能用 onGenerateRoute / pages 按名重建。不能序列化的闭包路由(builder: (_) => MyPage(obj) 里捕获了内存对象)恢复不了。改成「路由名 + primitive 参数」。
  • Scrollable.restorationId: 存 offset。列表数据源若恢复后条数变了,offset 可能越界,要能 clamp。
  • TextField + RestorableTextEditingController: 存文本和选择。不存焦点(焦点恢复另说,且不一定符合平台习惯)。
  • TabBarView / PageView: 存 index。

ModalRoute 上的临时 dialog 要不要恢复,是产品问题。恢复一个已经无意义的「确认删除」对话框,比丢了更糟。显式不给 dialog restorationId。

5. 多 Engine、Group、混合栈

每份 Engine 自己的 RestorationManager。Android 的 savedInstanceState 是 Activity 级。多个 FlutterView 共用一个 Activity 时,Embedder 必须用 不同的 restoration id 前缀 把多份字节塞进同一 Bundle。撞 id 就是互相覆盖,表现为「打开钱包,设置页的滚动跑到了钱包」。

Group spawn 的新 Isolate 不继承上一份 Isolate 的 bucket,除非 Embedder 把字节传进去。页面 destroy 再开,是新 Engine 还是复用,决定要不要从系统 bundle 恢复。短命的 Flutter 模块若每次 destroy Engine,应把「用户以为还在」的状态放到原生 Activity 的 view model 或磁盘,不要指望 Dart restoration——Engine 都没了,scope 也没了。

插件状态:原生单例活过进程吗?不活。权限、蓝牙连接、相机开着,全部要在新进程重新来。Restoration 只还原 UI 外观,不还原设备占用。

6. 内存、大小、线程

序列化发生在 UI Isolate,结果交给 Platform 线程写入 Bundle。大对象会卡最后一帧和 onSaveInstanceState(Android 上后者超时是直接丢状态或崩溃)。

预算:

  • 只存 UI 需要的 primitive:int offset、string 文本、小 map。
  • 图片、列表全量数据、下载中的文件,不进 bucket。
  • 版本字段必须有。App 升级后旧字节反序列化失败,应丢弃并走默认 UI,而不是抛给 runApp。
class RestorableCartCount extends RestorableValue<int> {
  @override
  int createDefaultValue() => 0;

  @override
  void didUpdateValue(int? oldValue) => notifyListeners();

  @override
  int fromPrimitives(Object? data) {
    if (data is! Map) return 0;
    if (data['v'] != 1) return 0;
    return data['n'] as int? ?? 0;
  }

  @override
  Object toPrimitives() => {'v': 1, 'n': value};
}

没有版本号,下次你把 n 改成 list,老用户一回来就崩在启动路径——那种崩比丢状态更差。

7. 设计取舍

  • 交给操作系统保管而不是自己写文件: 和 Activity 生命周期对齐,杀进程后仍能「无缝」。代价是大小和必须是 primitive,以及用户主动 kill 可能不走 onSaveInstanceState。
  • opt-in id: 默认不恢复,避免意外把不该活的 dialog 救活。代价是忘写 id 等于没做。
  • 第一帧前灌回: 用户不看到闪回首页。代价是 initState 时恢复值还没就绪,初始化顺序变复杂。
  • 不恢复登录会话: 安全。代价是进程被杀后要重新走 token 刷新——那是你的会话层,不是 restoration 的活。

和「把一切放 Provider 全局」比:全局内存状态对进程死无能为力。该全局的(主题、已登录用户缓存)用自己的持久化;该跟这一屏走的用 restoration。混在一个 Store 里,要么 Store 大到不能写进 Bundle,要么杀进程丢整店。

8. 排障

  1. 杀进程回来是首页: restorationScopeId 没设、路由是闭包、深页没有 id、onGenerateRoute 认不出旧名。
  2. 文本回来了滚动没回来: Scrollable 没 id,或数据源异步到达,第一帧 offset 应用到空列表被夹成 0。数据到达后要再应用一次,或等数据再 build 列表。
  3. 偶发崩溃在启动: fromPrimitives 没容忍旧版本 / null。永远防御。
  4. 两页状态串: 兄弟 id 重复,或多 Engine 前缀撞。
  5. Debug 正常,线上丢: Debug 很少被 LMK。用「Don't keep activities」开发者选项强制杀。这是 Android 上测 restoration 的标准方法。
  6. 恢复出无意义 dialog: 给 dialog 关掉 restoration。
  7. EngineGroup 第二页空白: 字节没传到这份 Engine。查 Embedder 是否按 view id 存取。

flutter run 加系统「不保留活动」比写再多单测都接近真实。单测用 tester.restartAndRestore() 覆盖逻辑,真机覆盖 Embedder 和 Bundle 大小。

9. 和会话、草稿怎么分工

一张订单页:

  • Restoration: 当前填到第几步、输入框文本、键盘是否需要(通常不恢复键盘)、滚动位置。
  • 草稿存储: 用户点「保存草稿」或 debounce 写磁盘的完整模型,带用户 id 和版本。
  • 会话: token 在安全存储,进程回来后刷新。失败则登录页,不要让 restoration 把你送进需要鉴权的深页再 401。

三层混一层,会出现「杀进程后带着过期 token 打开了支付页」。恢复导航栈之前先问会话还有没有。

10. Navigator 2、RestorableRouteFuture、以及不能捕获的对象

声明式路由(Navigator.pages / Router)恢复时,必须能从 primitive 重建整个 pages 列表。典型写法:bucket 里存 List<String> stackIds,build 时 pages = stackIds.map(idToPage)。idToPage 是纯函数,参数来自 bucket 里另一组 map,不来自被杀前的内存对象。

RestorableRouteFuture 用来「恢复一个尚未 pop 的 dialog/route」:进程被杀时 dialog 开着,回来还开着。它要求 onPresent 用 restorablePush,参数可编码。showDialog(builder: (_) => X(myObj)) 这种捕获 myObj 的,恢复时 myObj 是另一个 isolate 里的虚无。改成 restorablePush + arguments: {'id': x},新进程按 id 从 repository 再取。repository 自己要能在冷启动后读盘,这已经超出 restoration,是你的数据层。

late RestorableRouteFuture<void> _confirm;

@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
  registerForRestoration(_confirm, 'confirm');
}

void _open() {
  _confirm.present();
}

onGenerateRoute 必须认识你用过的所有名字,包括已经下线但仍可能躺在用户 Bundle 里的旧名。旧名映射到首页或丢弃,不要 throw。Bundle 可能活过好几个 App 版本——用户两周没打开,系统一直持有旧状态。

RootRestorationScope 包在 WidgetsApp 之上或之内,决定根桶。测试里 tester.restartAndRestore() 依赖这套。缺少 scope,测试会绿(因为没恢复可测),真机红。

RestorationBucket.claimChild 失败(id 冲突)是断言级错误。动态列表里用 index 当 restorationId,插入一项就冲突或错位。用业务 id。和 Element Key 是同一纪律。

11. 测法、Bundle 限额、主动退出

Android 开发者选项 不要保留活动 是必测,不是可选。另外:

  • 从深页按 Home,用 adb shell am kill <pkg> 杀进程,再从概览页回来。走的是「最近任务恢复」,和「冷点图标」可能不同。两者都要测。
  • 旋转、分屏、语言切换会走 onSaveInstanceState 但不一定杀进程。restoration 和 didChangeDependencies 会一起发生。MediaQuery 变了,滚动 offset 仍应按逻辑像素恢复。
  • Bundle 过大:TransactionTooLargeException。症状是「有时能恢复有时进程直接没了」。把 list 全量塞进 bucket 就会。用 adb logcat 搜这个异常。

iOS:stateRestorationActivity / scene restoration。用户从多任务划掉,通常 不 恢复——这是平台语义,别在 iOS 上强求和 Android LMK 一样。测 iOS 用「模拟内存警告」不如 Android 的 Don't keep activities 硬。两端产品预期要写进 QA 用例,不要当 bug 互抄。

用户从设置里「强制停止」、或你自己 exit(0)、或登录页点退出:应 RestorationManager.rootBucket 清空或换 scope id,避免下一个用户(或同一用户登出后)恢复出上一个人的表单。登出是业务事件,要显式 ServicesBinding.instance.restorationManager.flush() 之前先清敏感 Restorable。token 本就不该在 bucket 里。

RestorableProperty 的 notifyListeners 会标脏桶,真正序列化是批量的。在 onChanged 里同步改 50 个 Restorable 没问题;在动画里每帧改 Restorable 会让序列化逻辑一直认为有活要干。滚动 offset 由 Scrollable 自己节流。你自定义的进度条不要每帧 value = t 进 Restorable,松手再写。

和 Engine detached:混合栈里 Activity 还在、FlutterView detach 再 attach,可能是同一 Engine 也可能不是。同一 Engine 不需要 restoration,State 还在。新 Engine 需要。容器作者必须知道这次 attach 是哪一种,不要两种都灌 bucket 导致「恢复到更旧的一份」。

第一帧闪烁:数据层比 bucket 慢

bucket 能恢复 offset 和文本,恢复不了「列表的 100 条」。常见闪烁:第一帧空列表(offset 被夹成 0)→ 异步 repository 返回 → 列表出现但停在顶。修法:

  • 同步可恢复的数据快照也放磁盘,启动时同步读(注意不要在 UI 线程读大文件,用启动 isolate 或 native 已经 load 的 cache)。
  • 或 bucket 里存 hasLoaded 和 itemCount,第一帧先建占位高度让 offset 合法,数据到来再替换。占位高度错了会跳,用估算 extent。
  • 不要在恢复路径上 push 一个 loading 路由盖住深页,那等于自己毁掉「第一帧就是深页」。

FutureBuilder 的 ConnectionState.waiting 在恢复后仍会走一遍 waiting,除非 future 已经是 completed。用「内存已有快照则同步出数据」的 repository,恢复才稳。

敏感字段

密码、验证码、证件号:不要进 Restorable。系统备份、共享平板、bugreport 都可能带出 savedInstanceState。TextField 的 obscureText 不会自动让 Restorable 加密。显式用普通 TextEditingController 并在 restoreState 里不注册。

用户 id 变了(切换账号)必须换 restorationScopeId 或清根桶。否则账号 B 打开 App 看到账号 A 的草稿。这是安全 bug,不是体验细节。

与路由信息、深度链接同时到达

冷启动可能同时:系统要你恢复旧栈,深度链接要你打开新页。产品要定义优先级。常见:深度链接赢,丢弃 bucket 栈;或恢复后再 push 链接。两者都做会栈错乱。在 RouteInformationParser 里读一个「是否正在 restoration」的标志,只选一条路。这个标志在 initialRestore 为 true 的那次 restoreState 设置,第一帧后清掉。

RestorationManager.flush 与何时写盘

框架在 onSaveInstanceState 路径上向 Dart 要数据,会 flush 把脏 Restorable 收成 primitive。你自己改了 Restorable 却在同一调用栈里立刻杀进程(测试里),可能还没 flush。测试用 await tester.restartAndRestore() 会走正确时序。真机 Home 键通常够时间;am kill 在 paused 之后,也通常已经 save。exit(0) 可能不走 save。崩溃(signal)一定不走。restoration 不是崩溃恢复。崩溃恢复是你的磁盘草稿 + 崩溃前 debounce。

BindingBase 的 restoration 编码失败会打日志并可能丢整桶。fromPrimitives 抛错比丢数据更糟——启动失败。所有 fromPrimitives 包 try,失败回 default。把这件事写成 lint 级别的纪律。

多窗口、桌面、Web

桌面多窗口可能多 Engine 或多 Window。restoration id 要含窗口 id。Web 的「刷新页面」不是 Android LMK,是你自己的 url + 浏览器会话。用 url 表达栈,比 RestorationMixin 更符合 Web。不要在 Web 上期待 savedInstanceState。kIsWeb 时把「刷新后回到哪」交给 router path。

最小恢复集:写进页面模板

每个会 push 的页面在模板里回答:

  • restorationId 是什么(业务名,不是 index)
  • 哪些 Restorable(offset、tab、文本)
  • 哪些不恢复(密码、一次性 dialog、播放位置是否恢复由产品定)
  • 数据层如何在第一帧前给出非空快照
  • 登出如何清桶

没有这五行,页面上线后 Android 低内存机就是抽奖。抽奖结果是 1 星评论「怎么又从首页开始」。把五行当 code review 清单,比事后补 RestorationMixin 便宜。

Restorable 与动画控制器

AnimationController 不要进 bucket。恢复后动画应停在终态或静止,而不是从 0 再播一遍开场动画。用 RestorableBool shown 表示已经做过入场。否则每次 LMK 回来用户都看一次闪屏动画,既烦又慢。

测试矩阵

最少:

  1. tester.restartAndRestore() 表单文本、滚动、tab。
  2. Don't keep activities + 真机深页 Home 再回。
  3. 升级版本:旧 Bundle 新代码,fromPrimitives 不崩。
  4. 登出再进,不见上用户数据。
  5. 深度链接与恢复的优先级。

五条里第 2 和第 4 最容易漏。漏 2 是 Android 差评,漏 4 是事故。

和 EngineGroup 一起时的保存键

Bundle key = activityId + flutterViewId + dartRestorationId。漏任何一段都会撞。把 key 打印在 Debug。第二份 Flutter 页恢复出第一份的滚动位置,就是 key 撞了。修 key,不要修业务代码。

状态恢复的验收必须在真机用 Don't keep activities 跑主路径,而不是只靠 widget 测试绿。测试绿证明 fromPrimitives 对称;真机证明 Embedder、Bundle、Activity、多 Engine 前缀。两层都要。发布清单加一项:低内存 Android 机从支付页 Home 再回,仍在支付页且表单还在,且 token 仍有效或被正确送到登录。这一项比再写一篇 mixin 教程更能保护收入。

数据分级写进架构说明:会话(安全存储)、草稿(磁盘,用户拥有)、UI 恢复(Bundle,系统拥有)、内存(进程拥有)。四种存储四种丢失模型。混用导致「杀进程丢草稿」或「草稿进了 Bundle 超限崩」。评审新页面时问四句:杀进程要回来吗?卸载要在吗?换账号要清吗?能不能进崩溃日志?答案决定落点。

Engine 生命周期上,detached 不是再跑一遍 restoreState 的时机,而是保证 下一次 冷启动能读到已经 flush 的桶。在 detached 里做耗时编码会和销毁抢主线程。Restorable 应在用户交互时就保持最新,destroy 时只 flush。不要等销毁再从各种 Controller 抠值——那时候树可能已经半拆,ScrollController 已经 dispose。这是时序不变量:写入分散在生命周期中,序列化集中在系统回调。分散写、集中刷。反过来会丢最后几秒的输入。

恢复数据的隐私审查:Bundle 可能进备份。Android Auto Backup 默认包含 savedInstanceState 相关数据视实现而定,审查时当「可能离开设备」处理。敏感字段已经禁止;半敏感(搜索历史)要产品点头。企业账号可能禁止备份,恢复仍应在 LMK 场景工作——那是 savedInstanceState,不是云备份。两种通路分清。

文档给 QA 的步骤必须具体:打开开发者选项 → 不保留活动 → 进到某页填某字段 → Home → 从最近任务回 → 期望。不要写「测试一下恢复」。步骤进测试用例库,版本升级时回归「旧 Bundle」。

代码生成:页面模板脚手架带 RestorationMixin、空 restoreState、restorationId 参数。比事后教育有效。脚手架里 fromPrimitives 已有 try/default。工程师只能填字段,不能忘纪律。

与导航 2.0 的 parser 测试:给一份旧 RouteInformation + 一份 bucket 字节,看 pages 列表。这是纯单元测试,不启引擎。把曾经生产过的栈形状做成黄金。导航重构时先跑这些黄金,再谈新路由图。恢复是路由系统的一部分,不是 State 的小插件。把它放进路由模块的职责,页面只注册自己的字段。职责清晰,桶的树才会和路由树同构,id 才不会在重构后漂掉。

恢复失败时的产品文案:不要崩溃,要「未能恢复上次进度,已回到首页」。打点 restoration_failed 带原因(版本、解码、id 冲突)。用户能继续用,你能知道规模。静默丢状态且无打点,会变成无法理解的「有人说闪回首页」。

与账号体系的时序:恢复栈之前先恢复会话。会话异步时,第一帧不要急着 Navigator 恢复到支付页。可以先亮闪屏,会话成功再灌栈,失败去登录且清桶。闪屏多 200ms 比闪一下支付页再踢出去更可接受。这是体验上的时序不变量,和 restoreState 在 initState 之后那条技术时序同样硬。

最后,restoration 不是备份。不要用它同步多设备。多设备是你的云。系统桶只服务「这台设备这次被杀」。职责写进架构图,避免有人往桶里塞「用户的全部草稿」指望换机还能看见。换机看不见是正常。看得见反而是隐私事故。

恢复系统的最后一条纪律是:默认不恢复比错误恢复好。 不确定的 dialog、过期的活动页、已下线的路由名,丢。丢了用户从上一层进,仍可用。错误恢复把用户送进崩溃或空页,不可用。fromPrimitives 失败即丢该字段;路由名不认识即丢该层栈。贪婪恢复是事故。克制恢复是设计。把克制写进 mixin 模板的注释,提醒未来的自己不要把整个 bloc 塞进去。能塞不代表该塞。该塞的只有用户会认为「我没离开」的那一小撮像素状态。其余让数据层去做。Engine 生命周期只保证这一小撮能跨过 LMK 这道缝。缝很小,补丁也该小。小才能快,快才能赶在 onSaveInstanceState 超时之前写完。小、快、克制,三条是同一件事的三个方面。

把「小、快、克制」落实到字段级:每个 Restorable 字段在 PR 里用一句话回答「用户会认为没离开吗?」。答否就删。答是才留下并写版本号。这句话当 review 问题。问题比规范短,短才能每次都问。每次都问,桶才能保持小。桶小,onSaveInstanceState 才快,快才赶得上 LMK 之前那次写入。写入成功,用户才会觉得 Flutter 混合栈和原生一样「还在」。觉得「还在」,是状态恢复这篇文章唯一的产品结果。其余都是为这一句服务的机制。

进程被杀是 Android 的正常天气,不是异常风暴。把天气当风暴,就不会做 restoration,用户就会在晴天以外骂「怎么又从头来」。把天气当天气,就会在模板里留下 restorationId 和克制的字段。字段克制,写入才快,快才赶得上那次 save。赶得上,混合栈才在 LMK 之后仍像一个 App,而不是像两次冷启动夹着一次失望。失望很贵。机制很便宜。便宜的机制值得写成十二条标题的长文,就是因为贵的失望每天都在低内存设备上发生。发生一次,原理就值回这篇文章的阅读时间。

状态恢复跨过的缝是进程死亡,不是页面 dispose。dispose 用内存对象就够。死亡必须用 primitive 树。树要小,因为写盘窗口短;树要有版本,因为 App 会升级;树要按账号隔离,因为隐私;树要在第一帧前灌好,因为不能闪。四条把 restoration 从「加 mixin」变成「设计字段」。字段设计对了,代码是机械的。机械的东西适合模板。模板里有 try/default、有 restorationId、有「用户会认为没离开吗」这句 review 问题。问题挡住贪婪。贪婪撑爆 Bundle,Bundle 失败比不恢复更糟。不恢复是从首页来。失败是启动崩。启动崩是最高级事故。所以克制是安全,不只是品味。安全、克制、小、快,在 onSaveInstanceState 这条时间线上是同一个约束。约束写进模板,天气就不再是风暴。Android 还是会杀进程。用户还是会以为没走。以为没走,就是这套机制唯一需要的幻觉。幻觉用字节换。字节要少。少,才能真的换到。

幻觉用少数字节换到之后,还要防幻觉错乱:错账号、错页、错版本。三错比没幻觉更伤。防错靠 scope id、业务 id、版本号、失败即丢。丢是安全默认。默认在模板里。模板在脚手架里。脚手架在每次新建页面时出现。出现,纪律才不靠记性。记性会在赶工时消失。消失时系统杀进程不会消失。杀进程仍是天气。天气里能用的,只有已经写进代码的那一小撮字段。字段不在,首页来。首页来,用户骂。骂完,再加 mixin 来得及补下一次,补不了这一次。这一次已经写进 1 星。所以模板要在第一次写页面时就在。第一次,就是这篇要求的工程位置。位置对了,天气随便来。

第一次写页面就带模板,是把天气响应做成肌肉记忆。记忆不靠人,靠脚手架。脚手架不在,人会忘。忘了,1 星来。来了再补,补的是下一次。下一次还要靠脚手架。所以脚手架不是可选生成器,是这条机制的安装程序。安装在仓库初始化里。初始化过了,每个新页默认克制。默认克制,Bundle 默认小。小,默认赶得上 save。赶得上,默认用户以为没走。默认,是工程对天气的态度。态度写进工具,才写进结果。

12. 小结

状态恢复是把 Dart UI 状态映射成系统可保管的 primitive 树,在新 Engine 的第一帧之前按 key 对号入座。它活过进程被杀,活不过你没注册的字段,也活不过用户卸载。restoreState 的时序、bucket 的 id、Bundle 的大小、多 Engine 的前缀,是四条不变量。登录态和大体量数据不要进这棵树。能在「Don't keep activities」下回到用户离开时的那一屏,混合栈在 Android LMK 上才算闭环。