
做一个像素画编辑器最早会遇到的不是 Canvas 怎么画而是数据怎么存。如果只是画一个 16x16 的网格直接用二维数组也能跑。但真正的编辑器会很快长出更多需求多图层、锁定、透明度、撤销重做、动画帧、项目保存、图片导入、GIF 导出。这个时候数据模型如果一开始没想清楚后面的功能会越写越别扭。bitArt 的模型集中在D:\HM\bitArt\entry\src\main\ets\models\EditorModels.ets画布创建逻辑在EditorCanvasService.ets。这篇专门拆它的数据结构。1. 从一个像素开始为什么用 string 表示颜色bitArt 的像素矩阵底层使用string[]每个元素保存一个颜色值。常见值有#RRGGBB#RRGGBBAArgba(r,g,b,a)透明像素常量这种方式不一定是内存最省的但在 ArkTS 应用层有几个明显好处调试直观看到数组就知道颜色。和 Canvas 的fillStyle接口天然兼容。和导入导出、图层混合、JSON 保存的转换成本低。在 8x8 到 256x256 这种移动端轻量画布范围内性能压力可控。bitArt 对画布尺寸做了限制export class EditorCanvasService { static readonly MIN_CANVAS_SIZE: number 8; static readonly MAX_CANVAS_SIZE: number 256; static normalizeCanvasDimension(size: number): number { if (size EditorCanvasService.MIN_CANVAS_SIZE) { return EditorCanvasService.MIN_CANVAS_SIZE; } if (size EditorCanvasService.MAX_CANVAS_SIZE) { return EditorCanvasService.MAX_CANVAS_SIZE; } return Math.round(size); } }这个上限不是随便写的。像素编辑器一旦支持多图层、多帧和撤销栈实际内存不是width * height而是画布像素数 * 图层数 * 帧数 * 历史快照数量所以 bitArt 默认把自定义画布控制在 256 以内是一个比较务实的移动端选择。2. LayerItem 只保存元信息像素单独保存bitArt 的图层模型很轻export interface LayerItem { id: string; name: string; visible: boolean; locked: boolean; opacity: number; }注意这里没有pixels字段。图层的像素数据放在单独的string[][] layersPixels里。也就是说LayerItem[] 保存图层元信息 layersPixels[][] 保存每个图层的像素矩阵这种拆法看起来多了一层对应关系但很适合编辑器图层改名、显隐、锁定不需要复制像素数组。图层像素变化时可以只替换某一个图层的string[]。图层栈操作时layers和layersPixels可以同步 splice。做撤销快照时元信息和像素数据都能明确 clone。在LayerStackService.addLayer中可以看到这种结构const nextLayers LayerStackService.cloneLayers(layers); const nextLayersPixels LayerCanvasService.cloneLayersPixels(layersPixels); const newLayerId layer- serial.toString(); nextLayers.splice(insertIndex, 0, { id: newLayerId, name: 图层 serial.toString(), visible: true, locked: false, opacity: 1 }); nextLayersPixels.splice( insertIndex, 0, PixelCanvasService.createTransparentPixels(width, height) );新增图层时元信息和像素矩阵一起插入同一个位置这样图层栈顺序就能保持一致。3. 新建画布默认图层加背景层新建空白画布时bitArt 默认创建两个图层layer-1可编辑透明图层。background锁定的白色背景层。核心逻辑在EditorCanvasService.createBlankCanvasstatic createBlankCanvas(width: number, height: number width): NewCanvasResult { const dimensions EditorCanvasService.normalizeCanvasDimensions(width, height); const layers: LayerItem[] [ { id: layer-1, name: 图层 1, visible: true, locked: false, opacity: 1 }, { id: background, name: 背景, visible: true, locked: true, opacity: 1 } ]; return { layers: layers, layersPixels: [ PixelCanvasService.createTransparentPixels(dimensions.width, dimensions.height), PixelCanvasService.createSolidPixels(dimensions.width, dimensions.height, #FFFFFF) ], activeLayer: layer-1, canvasWidth: dimensions.width, canvasHeight: dimensions.height, layerSerial: 2 }; }这个默认结构很接近常见图像编辑软件的心智模型用户画在上层背景层在底部兜底。背景层锁定可以避免用户无意中把底色擦空。4. Frame 是动画编辑的关键边界如果应用只做静态像素画项目里有一份图层数据就够了。但 bitArt 支持动画帧所以模型中有EditorFrameexport interface EditorFrame { id: string; name: string; durationMs: number; layers: LayerItem[]; layersPixels: string[][]; activeLayer: string; }这意味着每一帧都有自己的图层栈和像素矩阵而不是所有帧共用一套图层。这个设计很关键。动画编辑器里一帧的图层可见性、像素内容、当前激活图层都可能和另一帧不同。如果只保存“每帧一张合成图”后面想继续编辑图层就困难了如果所有帧共享图层切换帧时又容易互相污染。EditorFrameService.createFrame会深拷贝图层和像素static createFrame(id: string, name: string, durationMs: number, layers: LayerItem[], layersPixels: string[][], activeLayer: string): EditorFrame { return { id: id, name: name, durationMs: EditorFrameService.normalizeDuration(durationMs), layers: LayerStackService.cloneLayers(layers), layersPixels: LayerCanvasService.cloneLayersPixels(layersPixels), activeLayer: EditorFrameService.resolveActiveLayer(layers, activeLayer) }; }深拷贝的代价是内存更多但换来的是帧之间的数据隔离。对于编辑器来说这个取舍通常是值得的。5. Project 是可持久化的完整状态EditorPersistentProject是 bitArt 保存到本地的项目结构。它不仅保存画布像素还保存用户正在编辑时需要恢复的上下文export interface EditorPersistentProject { schemaVersion: number; projectId: string; projectName: string; updatedAt: number; canvasSize: number; canvasWidth?: number; canvasHeight?: number; activeTool: string; activeLayer: string; activeColor: string; activeFrame: string; frames: EditorFrame[]; gridVisible: boolean; autoSaveIntervalSeconds: number; exportImageFormat: string; layers: LayerItem[]; layersPixels: string[][]; undoStack: EditorSnapshot[]; redoStack: EditorSnapshot[]; }这里有几个细节值得注意。第一项目里同时有canvasSize、canvasWidth、canvasHeight。早期很多像素画工具只支持正方形画布但实际导入图片或自定义画布时独立宽高非常必要。第二项目保存了activeTool、activeColor、activeLayer、activeFrame。这让用户重新打开项目时可以回到接近离开时的状态。第三undoStack和redoStack也被保存了。很多轻量应用会在退出后丢弃撤销历史但 bitArt 把它纳入项目模型体验会更接近桌面编辑器。6. Snapshot 让撤销重做可恢复撤销快照模型是export interface EditorSnapshot { layers: LayerItem[]; layersPixels: string[][]; activeLayer: string; canvasSize: number; canvasWidth?: number; canvasHeight?: number; historyLabel?: string; }它没有保存全部项目字段而是只保存和一次编辑操作相关的画布状态。这个粒度比较合理撤销一次画笔操作时不应该把用户的导出格式、面板开合状态也一起回滚。在EditorHistoryService中记录历史时会 clone 快照并限制最大数量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; }这就是编辑器应用里常见的平衡撤销要可靠但不能无限占内存。7. 用隔离性检查模型是否可靠数据模型是否合格可以通过几组隔离测试判断。复制一个帧后修改新帧的图层像素原帧不能跟着变化图层改名、显隐和锁定时像素数组内容不应被重建创建 32×64 画布后每层像素数量必须始终等于 2048撤销一次画笔操作时导出格式、自动保存间隔和面板开合不能一起回退。项目序列化后再加载还要检查活动图层和活动帧是否仍指向有效 ID。若导入数据缺少可选宽高应从兼容字段恢复若帧时长、画布尺寸或历史数量越界则由服务层归一化。模型可靠的标准不是字段齐全而是复制、切换、保存和恢复后仍保持数据所有权。8. 这个模型的扩展空间bitArt 当前的数据模型已经能支撑一个完整移动端像素画编辑器。后续如果继续扩展还可以往几个方向演进如果画布尺寸继续放大可以把string[]改为更紧凑的二进制表示。如果颜色数量固定可以用调色板索引代替颜色字符串。如果撤销栈过大可以从完整快照切换到命令增量。如果需要跨设备同步可以给帧、图层、操作记录增加更稳定的版本字段。但在移动端 MVP 到成熟工具之间bitArt 当前的设计是很实用的模型清晰序列化简单服务层容易操作UI 层也不用理解底层细节。下一篇继续看画布渲染bitArt 如何在 ArkUI Canvas 上处理像素网格、透明色和脏区域刷新以及怎样合并同一行的连续色块来减少fillRect次数。