只要是编辑器撤销重做就是刚需。像素画编辑器尤其依赖撤销画错一笔、误删图层、导入图片位置不满意用户都希望可以回退。但撤销重做不是简单把所有状态塞进数组。画布有像素矩阵、图层、动画帧和历史数量限制如果设计不好很容易拖慢绘制或者占用过多内存。bitArt 的撤销重做核心在EditorHistoryService.ets。EditorHistoryWorkflowService.ets负责结束笔触、移动历史栈和应用快照BitArtEditorWorkspace.ets提供当前状态与刷新钩子Index.ets只保留页面生命周期和工作区挂载。1. bitArt 选择的是快照式撤销撤销重做常见有两种思路命令式记录“做了什么”撤销时执行反向操作。快照式记录“操作前是什么状态”撤销时恢复状态。bitArt 当前采用快照式。快照模型是export interface EditorSnapshot { layers: LayerItem[]; layersPixels: string[][]; activeLayer: string; canvasSize: number; canvasWidth?: number; canvasHeight?: number; historyLabel?: string; }它保存图层元信息、图层像素、当前图层和画布尺寸。没有保存所有项目设置这是一个比较合理的粒度撤销绘制时不应该连导出格式、自动保存间隔、面板开合状态一起回退。2. 记录历史时必须深拷贝像素数组是引用类型如果直接把当前数组推进栈里后续绘制会修改同一份数据历史记录就失效了。EditorHistoryService.cloneSnapshot会复制图层和每个像素数组static cloneSnapshot(snapshot: EditorSnapshot): EditorSnapshot { return { layers: LayerStackService.cloneLayers(snapshot.layers), layersPixels: snapshot.layersPixels.map((pixels: string[]) pixels.slice()), activeLayer: snapshot.activeLayer, canvasSize: snapshot.canvasSize, canvasWidth: snapshot.canvasWidth, canvasHeight: snapshot.canvasHeight, historyLabel: snapshot.historyLabel ? snapshot.historyLabel : }; }这一步不能省。编辑器里很多“撤销后画面不对”的问题本质都是历史记录保存了引用而不是值。3. 限制历史栈长度快照式撤销的缺点是占内存。bitArt 默认最大历史数量是 30export class EditorHistoryService { static readonly MAX_HISTORY: number 30; }记录快照时如果超过上限就丢掉最老的一条static record(undoStack: EditorSnapshot[], snapshot: EditorSnapshot, maxHistory: number EditorHistoryService.MAX_HISTORY): EditorSnapshot[] { const nextStack: EditorSnapshot[] undoStack.slice(); nextStack.push(EditorHistoryService.cloneSnapshot(snapshot)); if (nextStack.length EditorHistoryService.normalizeMaxHistory(maxHistory)) { nextStack.shift(); } return nextStack; }这个策略简单但有效。对于 256x256 以内的轻量像素画布配合历史数量上限快照方案的工程复杂度会比命令反转低很多。4. undo 和 redo 的移动规则撤销时把当前状态放入 redo 栈再从 undo 栈弹出上一个状态static undo(currentSnapshot: EditorSnapshot, undoStack: EditorSnapshot[], redoStack: EditorSnapshot[]): HistoryMoveResult { if (undoStack.length 0) { return { snapshot: currentSnapshot, undoStack: undoStack, redoStack: redoStack }; } const nextUndoStack: EditorSnapshot[] undoStack.slice(); const previousSnapshot nextUndoStack.pop() as EditorSnapshot; const nextRedoStack: EditorSnapshot[] redoStack.slice(); nextRedoStack.push(EditorHistoryService.cloneSnapshot(currentSnapshot)); return { snapshot: EditorHistoryService.cloneSnapshot(previousSnapshot), undoStack: nextUndoStack, redoStack: nextRedoStack }; }重做时方向相反从 redo 栈取出未来状态再把当前状态记录进 undo 栈。static redo(currentSnapshot: EditorSnapshot, undoStack: EditorSnapshot[], redoStack: EditorSnapshot[], maxHistory: number EditorHistoryService.MAX_HISTORY): HistoryMoveResult { if (redoStack.length 0) { return { snapshot: currentSnapshot, undoStack: undoStack, redoStack: redoStack }; } const nextRedoStack: EditorSnapshot[] redoStack.slice(); const nextSnapshot nextRedoStack.pop() as EditorSnapshot; const nextUndoStack EditorHistoryService.record(undoStack, currentSnapshot, maxHistory); return { snapshot: EditorHistoryService.cloneSnapshot(nextSnapshot), undoStack: nextUndoStack, redoStack: nextRedoStack }; }这套规则很经典但实现时要注意一个细节应用快照和更新栈状态要保持一致否则 UI 显示和历史栈会错位。5. 工作流层如何应用快照工作区的undo()只调用历史工作流。真正的执行顺序放在EditorHistoryWorkflowService.undo中先结束当前笔触再取得当前快照并调用历史服务最后应用结果并替换两个栈。static undo(target: EditorHistoryWorkflowTarget, hooks: EditorHistoryWorkflowHooks): void { hooks.endStroke(); const result EditorHistoryService.undo( hooks.getSnapshot(), target.undoStack, target.redoStack); hooks.applySnapshot(result.snapshot); target.undoStack result.undoStack; target.redoStack result.redoStack; }endStroke()很关键。用户正在拖动时如果突然撤销必须先把当前笔触状态收束否则上一点、当前点、临时形状预览都可能影响恢复结果。恢复快照时EditorCanvasStateService.applySnapshot重新设置图层、像素和活动图层工作区随后同步名称、刷新界面并标记项目已修改。这个过程会触发画布重新渲染也会让自动保存知道当前状态已经变化。6. 新操作会清空 redo 栈撤销之后如果用户又画了一笔未来历史就不再成立redo 栈应该清空。bitArt 在多处操作后都会做this.recordUndoSnapshot(previousSnapshot, 导入图片); this.redoStack [];这是很多撤销系统容易漏掉的规则。只要从历史中间分叉出新操作原来的 redo 路径就应该作废。7. 历史标签让用户知道自己在撤销什么bitArt 的EditorSnapshot里有historyLabelEditorHistoryTimelineService会根据它生成历史时间线文案。虽然这是 UI 细节但对复杂编辑器很有帮助。用户看到的不只是“第 1 步、第 2 步”而是类似“铅笔 3”、“导入图片”、“清空图层”这样的操作标签。撤销重做从纯技术能力变成了可理解的编辑历史。8. 什么时候该从快照切到命令bitArt 当前使用快照是合理的因为默认画布上限是 256历史数量也有限。但如果后续支持更大画布、更长动画、更高图层数量就可以考虑命令式或增量式历史。比如铅笔操作只记录被修改像素的旧值和新值。填充操作记录受影响像素集合。图层改名只记录旧名称和新名称。图层合并保存合并前的两层数据。命令式更省内存但实现复杂度也更高。对移动端创作工具来说先用快照把行为做稳再按性能瓶颈局部优化是更稳妥的路线。9. 用分叉历史验证撤销重做检查撤销重做时需要确认这些规则同时成立快照只保存编辑相关状态不保存全部应用状态。记录历史必须深拷贝图层和像素数组。历史栈必须有上限。undo 时当前状态进 redoredo 时当前状态回 undo。新编辑操作会清空 redo 栈。撤销前先结束当前笔触。给历史快照加标签能提升用户理解成本。验证时可以连续画三笔撤销两次后再画一笔确认旧 redo 路径被清空随后测试图层合并、图片导入和选区移动确认每个完整操作只占一个历史步骤。把历史上限设为 10 后继续操作最早快照应按顺序淘汰当前画面不能变化。最后切到后台再回来检查持久化后的撤销和重做栈仍能恢复同一批快照。这套方案适合中小型 HarmonyOS 编辑器。只要画布尺寸和历史数量有边界它比命令反转更容易写对也更容易随功能增长保持稳定。下一篇进入动画帧重点检查每一帧如何拥有独立图层状态以及切帧前为什么必须同步当前画布。
原生鸿蒙像素画板实战 06:撤销重做设计
发布时间:2026/7/15 21:51:34
只要是编辑器撤销重做就是刚需。像素画编辑器尤其依赖撤销画错一笔、误删图层、导入图片位置不满意用户都希望可以回退。但撤销重做不是简单把所有状态塞进数组。画布有像素矩阵、图层、动画帧和历史数量限制如果设计不好很容易拖慢绘制或者占用过多内存。bitArt 的撤销重做核心在EditorHistoryService.ets。EditorHistoryWorkflowService.ets负责结束笔触、移动历史栈和应用快照BitArtEditorWorkspace.ets提供当前状态与刷新钩子Index.ets只保留页面生命周期和工作区挂载。1. bitArt 选择的是快照式撤销撤销重做常见有两种思路命令式记录“做了什么”撤销时执行反向操作。快照式记录“操作前是什么状态”撤销时恢复状态。bitArt 当前采用快照式。快照模型是export interface EditorSnapshot { layers: LayerItem[]; layersPixels: string[][]; activeLayer: string; canvasSize: number; canvasWidth?: number; canvasHeight?: number; historyLabel?: string; }它保存图层元信息、图层像素、当前图层和画布尺寸。没有保存所有项目设置这是一个比较合理的粒度撤销绘制时不应该连导出格式、自动保存间隔、面板开合状态一起回退。2. 记录历史时必须深拷贝像素数组是引用类型如果直接把当前数组推进栈里后续绘制会修改同一份数据历史记录就失效了。EditorHistoryService.cloneSnapshot会复制图层和每个像素数组static cloneSnapshot(snapshot: EditorSnapshot): EditorSnapshot { return { layers: LayerStackService.cloneLayers(snapshot.layers), layersPixels: snapshot.layersPixels.map((pixels: string[]) pixels.slice()), activeLayer: snapshot.activeLayer, canvasSize: snapshot.canvasSize, canvasWidth: snapshot.canvasWidth, canvasHeight: snapshot.canvasHeight, historyLabel: snapshot.historyLabel ? snapshot.historyLabel : }; }这一步不能省。编辑器里很多“撤销后画面不对”的问题本质都是历史记录保存了引用而不是值。3. 限制历史栈长度快照式撤销的缺点是占内存。bitArt 默认最大历史数量是 30export class EditorHistoryService { static readonly MAX_HISTORY: number 30; }记录快照时如果超过上限就丢掉最老的一条static record(undoStack: EditorSnapshot[], snapshot: EditorSnapshot, maxHistory: number EditorHistoryService.MAX_HISTORY): EditorSnapshot[] { const nextStack: EditorSnapshot[] undoStack.slice(); nextStack.push(EditorHistoryService.cloneSnapshot(snapshot)); if (nextStack.length EditorHistoryService.normalizeMaxHistory(maxHistory)) { nextStack.shift(); } return nextStack; }这个策略简单但有效。对于 256x256 以内的轻量像素画布配合历史数量上限快照方案的工程复杂度会比命令反转低很多。4. undo 和 redo 的移动规则撤销时把当前状态放入 redo 栈再从 undo 栈弹出上一个状态static undo(currentSnapshot: EditorSnapshot, undoStack: EditorSnapshot[], redoStack: EditorSnapshot[]): HistoryMoveResult { if (undoStack.length 0) { return { snapshot: currentSnapshot, undoStack: undoStack, redoStack: redoStack }; } const nextUndoStack: EditorSnapshot[] undoStack.slice(); const previousSnapshot nextUndoStack.pop() as EditorSnapshot; const nextRedoStack: EditorSnapshot[] redoStack.slice(); nextRedoStack.push(EditorHistoryService.cloneSnapshot(currentSnapshot)); return { snapshot: EditorHistoryService.cloneSnapshot(previousSnapshot), undoStack: nextUndoStack, redoStack: nextRedoStack }; }重做时方向相反从 redo 栈取出未来状态再把当前状态记录进 undo 栈。static redo(currentSnapshot: EditorSnapshot, undoStack: EditorSnapshot[], redoStack: EditorSnapshot[], maxHistory: number EditorHistoryService.MAX_HISTORY): HistoryMoveResult { if (redoStack.length 0) { return { snapshot: currentSnapshot, undoStack: undoStack, redoStack: redoStack }; } const nextRedoStack: EditorSnapshot[] redoStack.slice(); const nextSnapshot nextRedoStack.pop() as EditorSnapshot; const nextUndoStack EditorHistoryService.record(undoStack, currentSnapshot, maxHistory); return { snapshot: EditorHistoryService.cloneSnapshot(nextSnapshot), undoStack: nextUndoStack, redoStack: nextRedoStack }; }这套规则很经典但实现时要注意一个细节应用快照和更新栈状态要保持一致否则 UI 显示和历史栈会错位。5. 工作流层如何应用快照工作区的undo()只调用历史工作流。真正的执行顺序放在EditorHistoryWorkflowService.undo中先结束当前笔触再取得当前快照并调用历史服务最后应用结果并替换两个栈。static undo(target: EditorHistoryWorkflowTarget, hooks: EditorHistoryWorkflowHooks): void { hooks.endStroke(); const result EditorHistoryService.undo( hooks.getSnapshot(), target.undoStack, target.redoStack); hooks.applySnapshot(result.snapshot); target.undoStack result.undoStack; target.redoStack result.redoStack; }endStroke()很关键。用户正在拖动时如果突然撤销必须先把当前笔触状态收束否则上一点、当前点、临时形状预览都可能影响恢复结果。恢复快照时EditorCanvasStateService.applySnapshot重新设置图层、像素和活动图层工作区随后同步名称、刷新界面并标记项目已修改。这个过程会触发画布重新渲染也会让自动保存知道当前状态已经变化。6. 新操作会清空 redo 栈撤销之后如果用户又画了一笔未来历史就不再成立redo 栈应该清空。bitArt 在多处操作后都会做this.recordUndoSnapshot(previousSnapshot, 导入图片); this.redoStack [];这是很多撤销系统容易漏掉的规则。只要从历史中间分叉出新操作原来的 redo 路径就应该作废。7. 历史标签让用户知道自己在撤销什么bitArt 的EditorSnapshot里有historyLabelEditorHistoryTimelineService会根据它生成历史时间线文案。虽然这是 UI 细节但对复杂编辑器很有帮助。用户看到的不只是“第 1 步、第 2 步”而是类似“铅笔 3”、“导入图片”、“清空图层”这样的操作标签。撤销重做从纯技术能力变成了可理解的编辑历史。8. 什么时候该从快照切到命令bitArt 当前使用快照是合理的因为默认画布上限是 256历史数量也有限。但如果后续支持更大画布、更长动画、更高图层数量就可以考虑命令式或增量式历史。比如铅笔操作只记录被修改像素的旧值和新值。填充操作记录受影响像素集合。图层改名只记录旧名称和新名称。图层合并保存合并前的两层数据。命令式更省内存但实现复杂度也更高。对移动端创作工具来说先用快照把行为做稳再按性能瓶颈局部优化是更稳妥的路线。9. 用分叉历史验证撤销重做检查撤销重做时需要确认这些规则同时成立快照只保存编辑相关状态不保存全部应用状态。记录历史必须深拷贝图层和像素数组。历史栈必须有上限。undo 时当前状态进 redoredo 时当前状态回 undo。新编辑操作会清空 redo 栈。撤销前先结束当前笔触。给历史快照加标签能提升用户理解成本。验证时可以连续画三笔撤销两次后再画一笔确认旧 redo 路径被清空随后测试图层合并、图片导入和选区移动确认每个完整操作只占一个历史步骤。把历史上限设为 10 后继续操作最早快照应按顺序淘汰当前画面不能变化。最后切到后台再回来检查持久化后的撤销和重做栈仍能恢复同一批快照。这套方案适合中小型 HarmonyOS 编辑器。只要画布尺寸和历史数量有边界它比命令反转更容易写对也更容易随功能增长保持稳定。下一篇进入动画帧重点检查每一帧如何拥有独立图层状态以及切帧前为什么必须同步当前画布。