原生鸿蒙像素画板实战 08:项目持久化
像素编辑器最让人难受的故障不是某个按钮暂时点不动而是画了半小时后重新打开应用项目退回到更早的状态。这个问题经常不是“没有保存”这么简单。画布像素、图层、动画帧、撤销栈、调色板和面板状态分散在多个对象里页面进入后台、项目切换、导出文件和定时保存又会在不同时间触发写盘。只要其中一条路径漏掉同步磁盘里得到的就是一份表面完整、实际落后的项目。bitArt 把持久化拆成三个层次。EditorProjectSessionService.ets负责把运行时状态转换成可存储项目EditorPersistenceService.ets负责项目文件、清单、导入导出和旧格式兼容EditorSessionWorkflowService.ets负责恢复、脏标记、定时器和生命周期保存。页面只向工作流报告“项目变了”或“现在需要保存”不直接在每个交互里写文件。先定义什么叫一份可恢复的项目只保存最终合成图无法继续编辑只保存像素数组又会丢失图层和界面上下文。bitArt 的持久化项目包含画布尺寸、图层元信息、每层像素、活动图层、动画帧、活动帧、颜色和工具设置、调色板、撤销重做栈、时间轴与面板状态等。项目恢复后用户不只是“看到同一张图”还应该能从原来的图层、帧和工具位置继续操作。持久化对象与页面状态没有完全共用同一个类型。会话服务通过createPersistentProject组装数据通过applyPersistentProject恢复数据。这层转换很有价值页面可以继续使用适合响应式刷新的字段磁盘格式则保持稳定、可校验并在加载时补默认值。下面只节选与画布、帧和图层有关的字段完整方法还会传入工具、设置与历史状态。static createPersistentProject(target: EditorProjectSessionSaveState, layerSerial: number, syncedFrames: EditorFrame[]): EditorPersistentProject { return EditorPersistenceService.createProject({ schemaVersion: EditorPersistenceService.SCHEMA_VERSION, projectId: target.currentProjectId, projectName: target.currentProjectName, updatedAt: Date.now(), canvasWidth: target.canvasWidth, canvasHeight: target.canvasHeight, activeFrame: target.activeFrame, activeLayer: target.activeLayer, frames: syncedFrames, layerSerial: layerSerial, layers: target.editorLayers, layersPixels: target.layersPixels, // 其余工具、设置与历史字段按当前状态传入 }); }这里的syncedFrames不是直接拿页面里的旧帧数组。保存前要先把当前工作区图层写回活动帧否则用户最后几笔只存在于画布状态重新打开项目时会消失。一个项目一个文件再用清单管理入口早期实现常见做法是把所有项目塞进一个大 JSON。项目少时很方便项目多了以后每保存一笔都要重写全部内容任一损坏也会影响所有项目。bitArt 使用editor/projects/{projectId}.json保存单个项目用projects_manifest.json保存项目摘要和当前活动项目。filesDir/ editor/ projects_manifest.json projects/ project-1710000000000-a1b2.json project-1710000123456-c3d4.json清单只承担快速列出项目和确定上次打开项的职责像素矩阵仍留在各自项目文件里。进入项目管理面板时不需要解析所有大文件真正打开某个项目时再读取对应 JSON。删除项目时服务先删除项目文件再从清单中移除摘要若删除的是活动项目就把清单的活动 ID 调整到剩余项目。保存前先归一化加载后再校验磁盘数据不能因为来自本应用就默认可信。旧版本字段可能缺失导入文件可能被修改某个数值也可能超出当前界面能处理的范围。EditorPersistenceService.createProject和加载归一化流程会重新处理画布尺寸、自动保存间隔、导出倍率、历史数量、颜色、帧时长和活动 ID。autoSaveIntervalSeconds: EditorSettingsService.normalizeAutoSaveSeconds( payload.autoSaveIntervalSeconds), exportImageFormat: EditorSettingsService.normalizeExportFormat(payload.exportImageFormat), exportScale: EditorSettingsService.normalizeExportScale( payload.exportScale ? payload.exportScale : EditorSettingsService.DEFAULT_EXPORT_SCALE), maxHistoryCount: EditorSettingsService.normalizeHistoryCount(payload.maxHistoryCount)例如自动保存小于等于零表示关闭非零值限制在 60 秒到 86400 秒之间导出倍率限制为 1 到 4非法颜色回退到默认背景色。归一化放在服务层而不是只靠设置面板可同时覆盖本地恢复、项目导入和未来迁移。不要在每个像素落下时写磁盘画笔移动事件可能在一秒内触发几十次。每次都序列化多层像素并写文件会直接拖慢触控还会增加存储写入。bitArt 的做法是把编辑行为分为“状态已变化”和“现在需要落盘”两件事。画布、图层、帧、设置和历史发生有效变化时只调用markProjectDirtystatic markProjectDirty(target: EditorSessionWorkflowTarget): void { if (!target.restoreCompleted) { return; } target.projectDirty true; }真正保存时saveNow(false)会先检查脏标记没有变化就直接返回。显式保存、切换项目或导出前可以调用saveNow(true)强制组装并写入当前完整状态。这样触控路径只有内存修改写盘集中在可控节点。恢复期间不允许标脏也很重要。若应用刚把磁盘项目应用到页面响应式更新又触发设置变化立即标脏并覆盖文件可能把尚未恢复完整的默认状态写回去。restoreCompleted是这条边界的闸门。自动保存用单次定时器循环更容易收口bitArt 提供 5、15、30 分钟和自定义间隔。工作流没有长期保留一个无法调整的setInterval而是每次安排一个setTimeout到点后保存再按当前设置安排下一次。用户修改间隔或页面隐藏时旧定时器可以明确清除。static scheduleAutoSave(target: EditorSessionWorkflowTarget, hooks: EditorSessionWorkflowHooks): void { EditorSessionWorkflowService.clearAutoSaveTimer(target); if (!target.restoreCompleted || target.autoSaveIntervalSeconds 0) { return; } target.autoSaveTimerId setTimeout(() { EditorSessionWorkflowService.handleAutoSaveTick(target, hooks); }, target.autoSaveIntervalSeconds * 1000); }到点时若项目不脏saveNow(false)不会写文件但下一轮仍会继续安排。自动保存因此不是每隔几分钟必写一次而是每隔几分钟检查一次是否有值得保存的变化。生命周期保存要覆盖页面和 Ability 两端应用进入后台时页面未必有机会完成所有异步交互。bitArt 在页面隐藏时停止动画播放、清理自动保存定时器并刷新项目在页面重新显示时恢复定时器。EntryAbility的onBackground、onDestroy和onWindowStageDestroy还会递增一个AppStorage持久化信号工作区监听信号后触发保存。private requestEditorPersist(): void { const currentSignal AppStorage.getnumber(editorPersistSignal) ?? 0; AppStorage.setOrCreatenumber( editorPersistSignal, currentSignal 1); }这个信号没有把服务实例塞进 Ability也没有让 Ability 了解项目模型。Ability 只表达“生命周期即将离开”页面仍负责组装自己的状态。两端同时覆盖能降低仅依赖某一个页面回调所留下的缺口。项目导出与图片导出要分清图片导出得到 PNG、JPG 或 GIF适合分享和查看项目导出得到.bitartJSON目的是稍后继续编辑。exportProjectToFile使用DocumentViewPicker让用户选择位置再把归一化项目序列化写入 URI。导入项目时读取 JSON、解析、校验并分配新的项目 ID避免覆盖本机已有项目。const parsed JSON.parse(raw) as EditorPersistentProject; parsed.projectId EditorPersistenceService.createProjectId(); parsed.projectName EditorPersistenceService.normalizeProjectName( parsed.projectName, 导入项目); parsed.updatedAt Date.now(); const normalizedProject EditorPersistenceService.normalizeLoadedProject(parsed);导入后重新分配身份而不是相信文件内的projectId可以避免两个来源不同的项目写入同一路径。项目名称仍保留但会清理非法或空值。旧格式迁移要做成一次性兼容入口代码里保留了旧单文件名autosave_project.json。首次加载新清单失败时可以尝试读取旧文件把它规范化为新项目再写入项目目录和清单。迁移成功后后续都走新结构。兼容代码不应该散布在每次保存中。最佳位置是加载入口判断新格式是否存在必要时迁移一次再返回统一的EditorPersistentProject。这样主流程不需要反复判断版本旧文件也不会长期限制新结构演进。合并项目时要明确哪些状态不能继承bitArt 支持选择多个项目合成一个新项目。合并时以基础项目的画布尺寸为准把各项目图层复制到新图层栈并为重名图层追加编号。撤销和重做栈则清空因为原项目中的历史快照针对各自画布上下文直接拼接没有可靠语义。const project: EditorPersistentProject { // 画布、设置和合并后的图层省略 projectId: EditorPersistenceService.createProjectId(), projectName: mergedName, layers: mergedLayers, layersPixels: mergedLayersPixels, undoStack: [], redoStack: [] };这是持久化设计中很实际的取舍能安全迁移的数据尽量保留无法解释的数据宁可重置也不要制造看似可用、实际会恢复错对象的历史记录。怎样判断保存链路真的可靠验证不能只点一次保存再重开。可以建立一组故意为难状态创建两个非方形项目每个项目添加多层和多帧修改活动图层、帧时长、调色板、自动保存间隔和时间轴开合画一笔后立即切到另一项目再画一笔后直接把应用切到后台。重新进入后逐项确认项目列表、活动项目、像素、图层顺序、帧、历史和设置。还要检查四个容易漏掉的节点项目导出前是否同步最后一帧自动保存关闭后是否仍有旧定时器没有变化时是否反复写盘导入同一.bitart文件两次是否得到两个独立项目。若其中一个失败保存链路就还没有真正收口。持久化的目标不是把所有字段一股脑写成 JSON而是建立明确的数据所有权和保存时机运行时状态由页面维护项目快照由会话服务组装文件与清单由存储服务负责生命周期只发出保存信号。下一步再处理图片导入导出时也要沿用同样的边界因为相册 URI、临时文件、透明像素和动画帧会引入另一组容易混淆的状态。