像素编辑器最让人难受的故障,不是某个按钮暂时点不动,而是画了半小时后重新打开应用,项目退回到更早的状态。这个问题经常不是“没有保存”这么简单。画布像素、图层、动画帧、撤销栈、调色板和面板状态分散在多个对象里;页面进入后台、项目切换、导出文件和定时保存又会在不同时间触发写盘。只要其中一条路径漏掉同步,磁盘里得到的就是一份表面完整、实际落后的项目。

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 的做法是把编辑行为分为“状态已变化”和“现在需要落盘”两件事。

画布、图层、帧、设置和历史发生有效变化时,只调用 markProjectDirty

static 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 在页面隐藏时停止动画播放、清理自动保存定时器并刷新项目;在页面重新显示时恢复定时器。EntryAbilityonBackgroundonDestroyonWindowStageDestroy 还会递增一个 AppStorage 持久化信号,工作区监听信号后触发保存。

private requestEditorPersist(): void {
  const currentSignal =
    AppStorage.get<number>('editorPersistSignal') ?? 0;
  AppStorage.setOrCreate<number>(
    'editorPersistSignal', currentSignal + 1);
}

这个信号没有把服务实例塞进 Ability,也没有让 Ability 了解项目模型。Ability 只表达“生命周期即将离开”,页面仍负责组装自己的状态。两端同时覆盖,能降低仅依赖某一个页面回调所留下的缺口。

项目导出与图片导出要分清

图片导出得到 PNG、JPG 或 GIF,适合分享和查看;项目导出得到 .bitart JSON,目的是稍后继续编辑。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、临时文件、透明像素和动画帧会引入另一组容易混淆的状态。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐