原生鸿蒙像素画板实战 07:逐帧动画
给像素画编辑器增加动画,不是简单地在画布外面放一排编号按钮。静态编辑器只有一份图层状态,动画编辑器却要同时维护多份彼此隔离的画面,还要处理切帧前的同步、逐帧时长、缩略图、播放定时器、洋葱皮和导出。一旦状态边界没有划清,最常见的问题就是当前帧画好了,切到下一帧再回来却丢失;或者缩略图显示旧内容,播放时又突然跳成另一版。
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 毫秒,观察播放停留时间;删除中间帧后确认展示编号连续、内部活动帧仍正确;开启洋葱皮后确认辅助颜色没有写入导出结果。
做到这些,动画帧才算真正独立,而不是几张共享引用的画布快照。下一步自然会落到持久化:帧、图层、时长、面板状态和历史记录都已经变多,应用退到后台或重新打开时,必须能恢复到离开前的编辑现场。
更多推荐



所有评论(0)