持久化负责“关掉页面后还能回来”;Working Set、LOD 和资源预算负责“页面开着时不会把内存吃完”。把二者混为一谈,是大画布最常见的误判。

学习目标

  • 区分 Document、Workspace、Asset 与运行时对象;
  • 设计不依赖浏览器、Electron 或服务器的 Persistence Port;
  • 理解 Checkpoint + Journal 与当前 Undo/Redo 的区别;
  • 正确处理原子保存、版本迁移和 revision 不一致;
  • 按正确顺序恢复大文档,而不是全量 Hydrate。

1. Core 只依赖端口

interface DocumentPersistence {
  load(documentId: string): Promise<DocumentSnapshot | null>
  saveCheckpoint(documentId: string, snapshot: DocumentSnapshot): Promise<void>
  appendTransaction(documentId: string, tx: PersistedTransaction): Promise<void>
}

interface CanvasWorkspacePersistence {
  loadWorkspace(documentId: string): Promise<WorkspaceSnapshot | null>
  saveWorkspace(documentId: string, value: WorkspaceSnapshot): Promise<void>
}

interface AssetStore {
  getManifest(assetId: string): Promise<AssetManifest | null>
  putBlob(key: string, blob: Blob): Promise<void>
  getBlob(key: string): Promise<Blob | null>
  removeAsset(assetId: string): Promise<void>
}

浏览器实现 IndexedDB/OPFS,测试实现 Memory,桌面或服务器实现文件/数据库 Adapter。Core 不应该直接调用某个宿主 API。

2. 到底保存什么

数据 是否持久化 原因
Document records + schema version 作品真相
有界 Undo/Redo snapshot 可选 刷新后继续编辑
Checkpoint + transaction journal 崩溃与长期恢复
Camera/selection 可选 Session 恢复用户视角
Blob + Asset Manifest 素材真相
派生 LOD 可选 可重建缓存
ImageBitmap/Object URL/texture 运行时资源
Konva Node/Canvas 像素 可从 Document 重建

Object URL 只在当前页面生命周期有效,不能写进 Document 当永久地址。Document 只保存稳定 assetId

3. IndexedDB 为什么适合作为浏览器默认实现

IndexedDB 支持结构化数据、Blob 和事务,不像 localStorage 那样同步阻塞主线程。OPFS 更适合大文件和流式读写,但兼容性与实现复杂度更高,可以作为 AssetStore 的可选 Adapter。

IndexedDB:Document、History、Manifest、兼容性优先的 Blob
OPFS:可选的大文件 Blob
Memory:测试、隐私模式或存储不可用时降级

生成 LOD 时不要在图片解码的整个异步过程里一直占着数据库事务:先完成计算,再打开短事务写入结果。

4. Workspace 原子保存

若 Document 已保存到 revision 42,但 History 仍对应 revision 41,刷新后恢复历史会把作品带回错误分支。

interface WorkspaceSnapshot {
  version: 1
  document: DocumentSnapshot
  history: {
    documentRevision: number
    undo: TransactionSnapshot[]
    redo: TransactionSnapshot[]
  }
}

Document 与 History 应在同一个 IndexedDB transaction 中提交。恢复时:

history.documentRevision === document.revision
  → 可以恢复 History

不一致、损坏或版本不支持
  → 保留 Document
  → 丢弃 History

History 可以丢,Document 真相不能为了迁就撤销栈被覆盖。

5. Undo Stack 与长期 Journal 不同

Undo/Redo:当前编辑会话的用户体验,有步数和字节预算
Journal:Checkpoint 之后的持久化事务,用于崩溃恢复
Checkpoint:周期性的完整 Document 基线

长期恢复:

最近 Checkpoint
  → 按 revision 顺序重放后续 Journal
  → 得到最新 Document
  → 校验独立的 History snapshot

不能从作品诞生开始无限重放日志。达到事务数、字节数或时间阈值后生成新 Checkpoint,并清理已覆盖的旧日志。

6. Schema Migration

每份快照必须带版本。加载旧文档:

读取 raw snapshot
  → 验证基础结构
  → 逐版本 migrate v1 → v2 → v3
  → 校验迁移后的节点 schema
  → 才进入 DocumentStore

Renderer 不负责迁移;否则 Canvas2D 和 Konva 可能得到不同作品。迁移失败应明确报错或只读打开,不能静默丢字段。

7. 大文档的正确恢复顺序

加载并迁移 Document
  → 校验/恢复 History
  → 从全部轻量 bounds 重建 SpatialIndex
  → 恢复 Camera Session
  → 查询 Visible/Overscan
  → 只 Hydrate 当前 Working Set
  → 后台预热必要资源

错误做法是加载 Document 后立即为全部节点创建 Konva Node、解码全部图片,再显示首屏。持久化恢复不能重新引入全量渲染。

8. 写入调度与故障

Document 高频变化不代表每个 pointermove 都写数据库。常见策略:

pointermove:只更新内存 Document
pointerup / transaction commit:进入持久化队列
短 debounce:合并连续低风险写入
页面隐藏/关闭:尽力 flush,但不依赖 beforeunload 才保存

写入队列要保持顺序,并记录 revision。旧 revision 的迟到写入不能覆盖新快照。配额不足时优先清理可重建 LOD,而不是唯一 original。

9. 操作实验

  1. 用 Memory Adapter 保存 Document 与两条 History;
  2. 销毁 Runtime 和 Renderer,重新创建;
  3. 恢复后确认 Document 全量存在,但 Renderer 只挂载当前 Working Set;
  4. 人为把 History revision 改旧,确认只丢 History;
  5. 模拟写入失败,确认内存 Document 不被回滚成旧快照;
  6. 切换 IndexedDB Adapter,刷新页面后继续 Undo/Redo。

10. 验收标准

没有 Adapter 时 Memory 模式仍能运行
Document + History 原子保存或 History 安全丢弃
旧 schema 经过显式 migration
恢复后 renderedObjectCount 不等于 documentNodeCount
Blob/ObjectURL/ImageBitmap 边界正确
Camera/RBush/每帧 draw 热路径不等待持久化 I/O
写入顺序不会让旧 revision 覆盖新 revision

常见错误

  • 把 IndexedDB 当作运行时内存优化;
  • 把 Base64 或 Blob 塞进 Document JSON;
  • 保存 blob: URL;
  • 分两次保存 Document 和 History;
  • 恢复时全量创建 Renderer 对象;
  • 在 Camera 变化或每帧 draw 中写数据库;
  • 无版本快照直接进入 Core;
  • 删除节点时立即清理仍可能被 Undo 引用的素材。

思考题

为什么“Shell 已经保存整幅画布”不能证明图片解码内存已经被治理?请分别指出持久化、Working Set、Decoded Cache 和 Renderer Handle 解决的问题。