
给像素画编辑器增加动画不是简单地在画布外面放一排编号按钮。静态编辑器只有一份图层状态动画编辑器却要同时维护多份彼此隔离的画面还要处理切帧前的同步、逐帧时长、缩略图、播放定时器、洋葱皮和导出。一旦状态边界没有划清最常见的问题就是当前帧画好了切到下一帧再回来却丢失或者缩略图显示旧内容播放时又突然跳成另一版。bitArt 把这部分拆成模型、纯数据服务、工作流服务和时间轴组件。EditorFrameService.ets负责帧的创建、复制、删除和时长归一化EditorFrameWorkflowService.ets负责切帧与播放时的顺序EditorPreviewService.ets负责低成本缩略图EditorTimelinePanel.ets只处理时间轴展示和操作入口。这种拆法的关键不是文件数量而是让“数据怎么变”和“界面什么时候触发变化”互不混杂。先确定一帧真正拥有什么一帧不能只保存一张合成后的位图。只保存最终画面播放当然够用但切回这一帧继续编辑时图层可见性、锁定状态、透明度和当前图层都会消失。bitArt 的EditorFrame因此保存完整的帧内编辑状态export interface EditorFrame { id: string; name: string; durationMs: number; layers: LayerItem[]; layersPixels: string[][]; activeLayer: string; }这里最容易误判的是layers和layersPixels的关系。前者是图层元信息后者是一组与图层顺序对应的像素数组。两者必须一起复制、一起切换。只复制像素而复用图层对象修改某一帧的透明度时会污染其他帧只复制图层而复用像素数组画笔落下后又会把多个帧一起改掉。createFrame统一调用LayerStackService.cloneLayers和LayerCanvasService.cloneLayersPixels。这相当于把深拷贝入口收在一个地方新增帧、复制帧、加载帧都不需要各自记住复制细节。static createFrame(id: string, name: string, durationMs: number, layers: LayerItem[], layersPixels: string[][], activeLayer: string): EditorFrame { return { id, name, durationMs: EditorFrameService.normalizeDuration(durationMs), layers: LayerStackService.cloneLayers(layers), layersPixels: LayerCanvasService.cloneLayersPixels(layersPixels), activeLayer: EditorFrameService.resolveActiveLayer(layers, activeLayer) }; }这也说明帧切换不是换一张图片而是换一套可继续编辑的工作上下文。切帧之前必须先同步当前画布编辑过程中当前画布使用的是工作区状态editorLayers、layersPixels和activeLayer。这么做可以避免每一次落笔都在帧数组里查找并替换对象也能让画布、图层面板和历史栈继续沿用静态编辑器的处理方式。但它带来一个额外步骤离开当前帧前必须把工作区状态写回活动帧。syncFrame会先克隆整个帧数组再替换指定帧。工作流层在切帧、播放、复制、保存和导出前调用同步入口保证后续读取到的是最新像素。static syncFrame(frames: EditorFrame[], frameId: string, layers: LayerItem[], layersPixels: string[][], activeLayer: string): EditorFrame[] { const nextFrames EditorFrameService.cloneFrames(frames); const frameIndex EditorFrameService.getFrameIndex(nextFrames, frameId); if (frameIndex 0) { return nextFrames; } nextFrames[frameIndex] EditorFrameService.createFrame( nextFrames[frameIndex].id, nextFrames[frameIndex].name, nextFrames[frameIndex].durationMs, layers, layersPixels, activeLayer ); return nextFrames; }切帧的顺序也很重要。bitArt 先停止播放再结束尚未收口的笔触然后同步并应用下一帧最后刷新图层名称和界面。若在触摸移动事件尚未结束时直接换帧迟到的移动事件可能落到新帧上形成很难复现的脏点。新建空白帧和复制帧不是同一种操作动画制作中“加一帧”通常有两种语义。空白帧用于开始下一张动作复制帧用于在上一张基础上做小改动。两者如果共用一个实现用户很容易误删背景或丢掉图层结构。bitArt 新建空白帧时保留图层列表和背景层像素其他图层换成透明像素复制帧则完整复制来源帧的图层、像素、活动图层和时长。这样空白帧仍保留项目统一的背景与图层工作方式而复制帧可以直接调整角色姿态。private static createBlankLayersPixels(layers: LayerItem[], layersPixels: string[][], canvasWidth: number, canvasHeight: number): string[][] { const result: string[][] []; for (let index 0; index layers.length; index) { if (layers[index].id background) { result.push(layersPixels[index].slice()); } else { result.push(PixelCanvasService.createTransparentPixels( canvasWidth, canvasHeight)); } } return result; }删除帧也设置了底线至少保留一帧。数据服务在数组长度小于等于一时直接返回副本工作流层则给出“至少保留一帧”的反馈。数据层防止非法状态交互层解释为什么没有执行两层职责各自完整。帧编号用于展示帧 ID 用于身份时间轴中的帧名会在增删后重新编号为 1、2、3但帧 ID 不跟着位置变化。比如删除第二帧后原来的第三帧可以显示为“2”内部仍保持自己的frame-3。这能避免活动帧、缩略图缓存或其他引用因为排序改变而指向错误对象。新 ID 通过扫描现有frame-N的最大序号得到而不是简单使用frames.length 1。如果先删除再新增后者可能复用旧 ID最大序号策略会继续向后分配减少缓存键和状态引用冲突。每一帧都要有自己的播放时长固定帧率适合简单预览却不适合像素动画。眨眼可以很快站立停顿可以更长关键姿势也常常需要延长。bitArt 把durationMs放进每个帧对象并限制在 40 到 1000 毫秒之间默认值为 120 毫秒。static normalizeDuration(durationMs: number): number { const resolved Math.round(Number(durationMs)); if (isNaN(resolved)) { return EditorFrameService.DEFAULT_FRAME_DURATION_MS; } return Math.max( EditorFrameService.MIN_FRAME_DURATION_MS, Math.min(EditorFrameService.MAX_FRAME_DURATION_MS, resolved) ); }归一化不能只放在滑块组件中因为项目导入、旧数据恢复和代码调用都可能绕过界面。服务层再次收口后任何入口得到的帧时长都在有效范围内。时间轴提供滑块和正负 10 毫秒微调横屏紧凑布局则使用较短的控制条单位始终明确显示为ms。播放循环要读取当前帧的时长播放不是固定间隔的setInterval。如果每帧时长不同下一次调度必须在切换完成后读取当前结果的frameDurationMs再启动一次setTimeout。这样每次回调只负责推进一帧停止时也只需要清除一个定时器。static advanceAnimationFrame(target: EditorFrameWorkflowTarget, hooks: EditorFrameWorkflowHooks): void { if (!target.animationPlaying || target.animationFrames.length 0) { return; } const result EditorFrameActionService.advanceFrame(target); if (!EditorFrameWorkflowService.applyFrameActionResult( target, result, hooks)) { return; } target.animationTimerId setTimeout(() { hooks.advanceAnimationFrame(); }, result.frameDurationMs); }开始播放前先同步活动帧并检查至少有两帧。切帧、增加、复制、删除、修改时长时都会停止播放避免定时器和手动操作同时修改活动帧。这里宁可让用户重新点一次播放也不要保留一个仍在后台推进的旧定时器。缩略图不能每次都完整合成时间轴如果有十几帧每次界面刷新都把每帧按原尺寸完整合成一遍会把本来轻量的面板变成性能热点。EditorPreviewService选择 8×8 采样每个预览格映射到源画布的中心采样点再调用图层合成方法得到颜色。对时间轴而言这个分辨率足以辨认姿态和色块。for (let row 0; row 8; row) { const sourceRow sampleIndex(row, 8, canvasHeight); for (let col 0; col 8; col) { const sourceCol sampleIndex(col, 8, canvasWidth); const color LayerCanvasService.getCompositePixel( sourceLayersPixels, sourceLayers, canvasWidth, sourceRow, sourceCol ); previewCells.push({ id: ${row}-${col}, color, baseColor: color }); } }服务还维护最多 160 组预览缓存缓存键包含帧 ID、活动状态、画布尺寸、背景色、时长和刷新版本。当前帧直接读取工作区像素非活动帧读取帧内副本这一点很关键用户正在画当前帧时缩略图不必等下一次显式同步才能反映变化。洋葱皮只读取相邻帧不修改画布洋葱皮的用途是把前一帧和后一帧以低透明度叠在当前画布上帮助判断动作位移。bitArt 先取得同步后的帧数组再按活动帧索引读取相邻帧最后合成为只读像素数组交给画布叠加层。首帧没有前一帧末帧没有后一帧越界时返回空数组。这种实现没有把洋葱皮写进当前像素也不进入撤销栈。它只是渲染辅助状态。正在连续绘制时画布还会暂时跳过较昂贵的洋葱皮叠加笔触结束后再恢复完整效果减少拖动期间的闪烁。时间轴布局要保证操作按钮始终可达手机横向空间不足时帧列表应该滚动而新增、播放等按钮保持固定展开时间轴后缩略图列表使用横向滚动。横屏侧栏高度更紧bitArt 把帧卡片放进可滚动网格把新增、删除、播放和展开按钮固定在底部。帧多于四个时用户仍能滚动查看而不会把主要操作挤出屏幕。验证这一套帧编辑链路不能只看播放按钮会动。至少要做几组交叉检查第一帧画红点第二帧画蓝点来回切换确认互不污染复制第二帧后修改像素确认原帧不变分别设置 40、120、500 和 1000 毫秒观察播放停留时间删除中间帧后确认展示编号连续、内部活动帧仍正确开启洋葱皮后确认辅助颜色没有写入导出结果。做到这些动画帧才算真正独立而不是几张共享引用的画布快照。下一步自然会落到持久化帧、图层、时长、面板状态和历史记录都已经变多应用退到后台或重新打开时必须能恢复到离开前的编辑现场。