Skip to main content

· 动画机制(目标架构与约束)

本文是动画机制的改造目标 + 约束(红线)+ 验收指标,面向"要动手改动画相关代码的人"。

  • 现有动画 API 的契约(配置形态、缓动、结束判定、非法配置处理)见 07 · 交互与动画
  • 性能实测数据与复跑口径见 04 · 渲染与性能
  • 本文只写结论:该做成什么样、哪些做法已经否掉、什么条件下算改好了。

1. 现状(一句话)

动画的求值层是健康的(单 rAF + 每帧一次 O(n) 时间插值,tween 只占单帧 1%~5%, 结束判定按时间、弹簧过冲、非法配置拒绝等护栏都有回归);问题全在"写值 → 重绘"这一段

  • 动画写值走 setState → 每帧无条件置 paramsDirty → 离屏位图每帧重建(文本实测 13× 成本差);
  • 脏矩形门控里的"脏组件计数 > 20% → 回退全量"让"多数组件同时动"必然整屏重绘(实测 2.7× 断崖);
  • stagger / 入场这类编排天然是"全员同时动",不先修上面两条,编排只会更慢。

2. 决策记录(哪些做法不做

不做原因
不用 CSS 变换做图元动画CSS 只作用于 <canvas> 元素整体,碰不到画布内的像素;对图元动画的代价远大于收益:命中检测会错位(实测 translate 可用,scale/rotate 全部错位)、位图缩放/旋转让文字发虚(必须再补一次"松手重绘")、视口出现两个真相来源。CSS 变换仅保留在应用层:整幕转场、DOM 覆盖层元素的进出场(见 09 路线图 的引擎/应用层边界)。
"每个动画一个定时器"不用讨论:全局单 rAF + 每帧一次 O(n) 遍历已是模型,实测 tween 成本 1k/5k/10k/20k = 0.1/0.3/0.6/1.3ms。
把内核做成通用补间库路径运动、复杂缓动曲线求解、富文本/竖排等交给应用层或第三方库;内核只保证「声明式动画 + 正确重绘 + 可扩展的缓动/插值注册」。缓动已开放(easing 传函数 / registerEasing)、取值类型已开放(数值 / 数组 / 颜色 / 带单位数字串),回调与方向也已就位(07 有完整契约)。
一层一个图元 / 无限分层每张画布都是实打实的内存(含 dpr 放大)与移动端硬上限;层数必须收敛(见 §3.1)。

3. 目标架构

3.1 分层渲染:静态层 + 动画层

  • 场景层(静态):不参与本帧动画的图元;只有内容真的变化才重绘。
  • 动画层:本帧真正在动的图元;每帧只重绘这一层的区域。
  • 约束:层数 ≤ 3(背景 / 内容 / 动画,或内容 / 动画);每个 canvas 层按 dpr 放大, 内存与移动端上限是真实约束,禁止"按图元分层"。
  • 这一层做完,stagger/入场动画才谈得上"只重绘动画层"。

落地形态(2026-09-13)—— 引擎给原语,应用按配方组织

原语作用
ICE.linkViewport(a, b) / ice.followViewport(source)两层视口双向/单向同步(setViewport / zoomAt 都覆盖;destroy() 自动解绑;内部防回环,链式与双向连接都能收敛)
ice.setInputPassthrough(true)覆盖层 canvas 置 pointer-events: none(关闭时还原为而不是 auto,不覆盖应用样式)。不加这条,上层会吃掉整屏指针事件,下层交互直接失效
事件归属过滤(DOMEventDispatcher 内置)同页多实例由全局拦截器广播事件,因此"按下/滚轮"事件必须按目标 canvas 归属:目标是别人的 canvas → 本实例忽略。移动/抬起不过滤,避免拖拽途中指针划过另一张画布时丢事件
ice.moveComponentTo(component, targetIce, targetParent?)跨实例迁移组件(连同子树):保持世界坐标(按矩阵换算,不照抄 left/top)、不销毁(监听/子树/动画配置都留着)、ice/ctx/evtBus 递归重绑、动画注册与选中态由目标实例接管。配套 ice.detachChild()(摘除不销毁)与 rebindComponentTree()(显式子树重绑——ICEGroup 的 AFTER_ADD 同步钩子是 once,迁移时不会自己触发)
exportSvg([layerA, layerB])多层矢量合成:数组顺序 = 叠加顺序(第一层在下);area: 'content'(默认)各层共用同一个覆盖全部层的内容包围盒 → 层间按世界坐标对齐;area: 'viewport'第一层的视口与画布尺寸(各层视口应已 linkViewport)。单层(传单个 target)输出与历史逐字节一致
composeLayersDataURL([layerA, layerB], opts) / composeLayersToCanvas(...)多层位图合成(PNG 截图 / 缩略图):按层序 drawImage 叠加,尺寸缺省取各层 canvas 最大者,可给背景色;运行时无离屏画布能力时抛稳定错误码 ICE_OFFSCREEN_CANVAS_UNSUPPORTEDice.toDataURL() 只拿得到自己那一层,分层导出必须用它)

配方(应用侧):

  1. 两层叠放(position: relative 容器 + 绝对定位的两张同尺寸 canvas);
  2. 静态层建好内容后渲染一次,之后只在内容变化时dirty
  3. 动画元素放上层;上层默认 setInputPassthrough(true)(需要交互时才关掉);
  4. 两层 ICE.linkViewport;滚轮/拖拽平移在任一层触发都会同步;
  5. 层数 ≤2~3;每层内存 ≈ 宽 × 高 × 4B × dpr²(1600×1000 在 dpr=1 约 6.4MB、dpr=2 约 25.6MB);
  6. 拖拽期间把元素提升到动画层、松手放回(Konva drag-layer 模式)用 sourceIce.moveComponentTo(component, animIce) / 反向迁移 —— 位置与选中态自动保持; 放回前记得清掉临时动画并 animationManager.remove()(示例见 examples/animation/layered-canvas.html)。
  7. 导出:矢量用 exportSvg([staticIce, animIce], { area: 'viewport', background: '#fff' }); 位图用 composeLayersDataURL([staticIce, animIce], { background: '#fff' })(示例页两个按钮已接)。

实测npm run bench:layers;10000 静态元素 —— 2000 文本 + 1000 折线 + 7000 方块 —— + 200 个动画标记):

方案单帧 p50动画期间静态层重绘次数
单画布(静态 + 动画同一实例,已在走局部重绘)26~34 ms
双实例分层(静态层画一次 + 动画层)0.4~0.6 ms(≈60×)0

收益随"动画元素数量"增长而收敛(动画层自己变成全量重绘),但分层不会更差 —— 它只是拿内存换"静态层一帧都不用重画"。

分层原语至此齐了(视口绑定 / 输入穿透 / 事件归属 / 跨实例迁移 / 多层导出)。

编排与运行时控制也已落地ice.animationManager.timeline()add / at: '+=N' / stagger / play / pause / resume / stop / restart / finished)与 component.setAnimation/removeAnimationmanager.replay/isAnimating。时间轴是调度器(把 at 折算成 delay),不是新的求值器 —— 所以前面所有性能机制(写值通道、量化、缓存复用、空闲停帧)对它自动生效。

3.2 写值与重绘解耦(动画专属写值通道)

  • 动画写值不得走通用 setState:纯平移(left/toptransform.translate)只置 dirty不置 paramsDirty;只有几何/量测相关的键(width/height、文本内容、wrap 等)才置 paramsDirty
  • 收益(实测口径):1000 个文本从 34.8ms(每帧重建位图)→ 2.6ms(23000 次位图纯平移复用); 实现要点是命中已有的 ObjectCache.refreshPosition(整数设备像素位移才复用)。
  • 落地形态(2026-09-13)ICEComponent.setState(patch, { paramsDirty })(省略 = 旧行为)+ ICEComponent.ANIMATION_SAFE_KEYS 白名单 + isAnimationSafeKey()AnimationManager 只在 "本帧写出的键全是安全键"时跳过 paramsDirty。白名单保守方向:未知键与未声明白名单的组件 一律照旧置脏(第三方组件零风险)。
  • 配套:设备像素量化AnimationManager.snapToDevicePixel,默认开)。位图复用要求位移是 整数设备像素,而动画每帧位移通常是小数;量化只作用于"飞行中的纯平移 + 当前可离屏缓存的组件", 且终点值永远精确写入to: 100.5 就落在 100.5)。单条动画可 snapToDevicePixel: false 关掉。

3.3 脏区判定:按面积而不是组件计数

结论(2026-09-13 实测后)保留计数门,不改。原假设「动画脏区面积小、却被按个数判死刑」在 真实模型里不成立;把计数门换成面积门既没拿到稳定收益,又会让"注定回退全量"的帧白花 ~30% 时间。 真要让"大量图元同时动"变便宜,用的是 §3.1 的分层渲染,不是这条门。下面是评估过程与数据。

  • 实测方法(真实浏览器 / Chromium / 1600×1000 / dpr 1;探针未入库):同一场景跑两遍 —— 一遍现状,一遍把 renderer.__preCount() 返回的 visible 放大 100 倍(= 计数门失效、其余判定不变), 于是可以直接读出「局部 vs 全量」的差值。图元为不可缓存的矢量小矩形(面积 < 缓存门槛)。
场景(5000 / 10000 图元)计数门现状 p50计数门失效 p50结论
20% 脏、成片3.2 / 8.0 ms(已走局部)2.8 / 6.7 ms(同为局部)差值在抖动范围内(±20%)
30% 脏、成片4.5 / 14.1 ms(全量)5.5 / 12.6 ms(局部)5000 图元时局部更慢、10000 图元时略快 —— 交叉点就在 20~30%,且低于噪声
10~30% 脏、散开4.2~16.3 ms(全量)5.0~21.6 ms(仍回退全量散开脏区的并集盒必然撞 0.35 面积阈值;白跑一遍收集 + 聚合,多花 ~30%
  • 为什么散开时面积门也救不了:脏块一散开,coalesceRegions 要么留很多块、要么塌缩成 覆盖全屏的并集盒 —— 两种结局都会撞 FULL_FALLBACK_AREA_RATIO。也就是说, "1 万图元里 2100 个分散在动"这个场景的天花板不是门控,而是"整屏重绘本来就贵"。 §5 里"≤6ms"的原始目标据此下调/改写(见 §5)。
  • 计数门真正的价值:它是一道零开销预检(只读 display/dirty 标志),让"注定回退全量"的帧 连"场景门控 + 逐组件算包围盒 + 聚合"都不必走 —— 上表最后一行的 ~30% 就是它省下来的。
  • 顺带修掉一个潜伏的卡帧缺陷(评估中撞出来的):coalesceRegions 的"挑代价最小的两块合并" 阶段是 O(k²),最多跑 k 轮(整体 O(k³))。脏块一多(≥25 且彼此不直接相接、但合并划算)就会 指数级变慢 —— 实测 100 块 108ms / 300 块 2.7s / 1000 块 111s(一帧卡死)。 现在加了聚合预算 MAX_COALESCE_REGIONS = 32:超预算直接塌缩成并集盒交给面积阈值 (保守解,最多回退全量,绝不画错)。修完 1000 块 1ms,并有单测钉住(含 2000 块、耗时小于 200ms)。
  • 任何门控改动都必须过逐像素一致性回归(脏矩形/离屏缓存与全量的像素契约不能破)—— 本次改动只影响"选哪块区域重画",两条路径的像素契约不变,现有像素回归全绿。

3.4 帧调度(已落地 2026-09-13

  • 空闲停帧FrameManager 从"无条件续帧"改成"按需续帧" —— 每个总线的宿主(ICE 实例)用 ice.needsFrame() 回答"这一帧还要吗"(有脏要重绘 / 有动画在推进 / setContinuousFrames(true))。 置脏(ice.dirty = true)与新动画入列(AnimationManager.add)/ 恢复(resume())都会 wake() 唤醒循环。 实测(真实浏览器):静止页面 500ms 内 0 次帧回调(改造前约 30 次);有动画时恢复;暂停/动画结束/清空后再次归零。 应用层若自己监听 ICE_FRAME_EVENT 做每帧计算(时钟、呼吸灯…),用 ice.setContinuousFrames(true) 保持常驻。
  • 次要动画降频:单条动画可写 fps(如 fps: 30)——采样是按时间的,跳过帧只改变采样密度、不改变运动曲线, 终点仍精确落值。
  • 减少动态效果prefers-reduced-motion: reduce(或应用层 ice.setReducedMotion(true))下, 动画不播放过程、直接落终态,并记一条 ICE_ANIM_REDUCED_MOTION 运行期诊断 (让开发者知道"没动"是用户偏好,而不是引擎坏了)。AnimationManager.reducedMotion 在构造时读系统偏好。
  • 拖拽期间跳过命中DOMEventDispatcher 对移动类事件本来就不做命中检测(只在按下/滚轮时 findTargetComponent), 且拖拽时指针被 capture —— 这条是既有行为,本轮以回归用例钉住(见 tests/event/DOMEventDispatcher.*e2e/visual/animation-scheduling.spec.ts)。

3.5 可选:命中与绘制分离

若后续"任意形状 + 任意变换 + 位图化重绘"三者并存导致命中几何维护成本过高, 再考虑给每层配一张命中画布(按颜色编码反查)。先评估内存(一张 hit 画布同样是满画布尺寸)。

状态(2026-09-14):未做,也未立项 —— 前置条件(命中几何维护成本过高)尚未出现; 真要动手前先评估"每层多一张满画布(含 dpr)"的内存代价与收益。

4. 约束(红线)

约束阈值说明
渲染层数≤ 3每层按 dpr 占内存;移动端有画布内存硬上限
同时动画的矩形/路径类组件≤ 10,000(2026-09-14 实测 60.6fps;20,000 → 35.1fps)见 04「规模基线」
同时动画的文本组件≤ 1,000~2,000(实测 2,000 文本 = 58.8fps)—— 写值通道落地后已放宽一个数量级见 04「规模基线」
动画帧的脏区占比走局部重绘;超过门限才允许全量门限维持 0.2(2026-09-13 实测后定案,见 §3.3)
动画写值不改 props、不动序列化运行时状态(startTime/finished)不得写进 props.animations
无障碍遵守 prefers-reduced-motion引擎级开关;Agent 生成的动画默认遵守
跨平台不得依赖 DOM(小程序/Node 同样要能跑)命中画布/分层如需 DOM 能力,必须降级为单层

5. 验收指标(改完必须达到)

以 2026-09-13 的实测基线(Apple M4 / Chromium 153 / 1600×1000 / dpr 1,见 04)为对照:

场景基线目标
2,000 个文本各挂一个 left 动画91.8ms/帧≤10ms/帧(量级对齐"位图复用"路径)
10,000 图元中 2,000 个在动(20% 脏,成片12.7ms/帧(回退全量)≤8ms/帧(走局部重绘;2026-09-13 实测 6.7~8.0ms,原"≤6ms"目标在噪声内不可达)
10,000 图元中 2,100 个在动(21% 脏,散开12.7ms/帧不设目标:散开时并集盒必然撞面积阈值,正解是 §3.1 分层渲染(实测单画布 2634ms → 分层 0.40.6ms)
空闲(无动画、无脏帧)rAF 常驻空转停帧
既有回归jest 全量 + Playwright 82 条 + 逐像素一致性全绿,不许下降

性能数字必须可复跑:已固化bench/anim/animation-bench.mjs(写值通道/量化,阈值 --check) 与 bench/layers/layering-bench.mjs(分层渲染),两个门槛都已接进 npm run verify:full。 一次性探针脚本一律不入库(结论写进本文,口径与原始数据见 04)。

6. 分期

  1. 性能地基:动画写值专用通道 + 脏区门控 + bench 固化(本文 §3.2 / §3.3 / §5)。 —— 写值通道与设备像素量化已落地(2026-09-13)setState(patch, { paramsDirty }) + 组件的「动画安全键」白名单 + AnimationManager.snapToDevicePixel(默认开、可单条关); 实测 1,000 个文本的平移动画 35.1ms → 2.7ms(位图复用率 100%),门槛进了 npm run bench:anim -- --check(已接进 verify:full)。 —— 门控已经评估完毕、结论是「不改」(2026-09-13,见 §3.3):面积门没有稳定收益、 散开场景反而多花 ~30%;评估中撞出并修掉了 coalesceRegions 的 O(k³) 卡帧缺陷 (聚合预算 + 单测)。
  2. 分层渲染:静态层 / 动画层(§3.1)。 —— 已落地(2026-09-13):视口绑定 / 输入穿透 / 多实例事件归属 / 跨实例迁移 / 多层导出合成 五个原语 + 应用配方 + 示例;实测 10000 静态元素 + 200 动画标记:26~34ms → 0.4~0.6ms(≈60×), 门槛 npm run bench:layers -- --check(已进 verify:full)。
  3. Agent 侧闭环:动画 DSL 的校验 + 稳定诊断码(另见 DSL 侧的诊断约定)。 —— 已落地(2026-09-13 / 09-14):引擎侧 validateAnimations() + ICE_ANIM_* 稳定码 + getDiagnostics();DSL 侧转述为 ICE_DSL_* + ICE_ANIM_*(带精确 path)。 —— 延伸项「编排声明化」也已落地(2026-09-14):DSL 顶层 orchestration.groups.*.tracks[] 声明错峰/时序与 autoplay,编译到引擎的 timeline()(调度器),并在 renderDsl() 返回 play / pause / resume / stop / restart / finished 句柄;新增 4 个编排诊断码与真浏览器 e2e。
  4. 帧调度与合规:降频 / 空闲停帧 / 拖拽跳过命中 / 屏外裁剪 / prefers-reduced-motion(§3.4)。 —— 已落地(2026-09-13):按需续帧(静止页面 500ms 内 0 次帧回调)、fps 降频、 prefers-reduced-motion 直接落终态、拖拽跳过命中(回归钉住)、屏外裁剪(既有 + 回归)。
  5. (另议)渲染后端:万级以上"同时动画"若成为常态,再评估 GPU(WebGL/WebGPU)后端与 OffscreenCanvas, 见 10 · Worker/OffscreenCanvas

规划内剩余(2026-09-14 盘点):只有 §3.5(可选,未立项)与第 5 条(另议,触发条件未到)。 两项都不是缺陷,而是"要不要为尚未出现的负载提前投资"的取舍 —— 动手前先做性能后端的量化评估。

7. 风险与回滚

  • 最大风险是像素一致性:写值通道与脏区门控都直接触碰"脏矩形 / 离屏缓存与全量逐像素一致"这条契约。 每一步都必须带像素回归;一旦不一致,用布尔开关退回旧路径(写值通道与门控都可以做成可回退的常量)。
  • 分层会改变坐标系与命中的假设:分层上线前要先明确"层间视口同步、命中归属、导出覆盖范围"三件事。

Live Demo