· 动画机制(目标架构与约束)
本文是动画机制的改造目标 + 约束(红线)+ 验收指标,面向"要动手改动画相关代码的人"。
- 现有动画 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_UNSUPPORTED(ice.toDataURL() 只拿得到自己那一层,分层导出必须用它) |
配方(应用侧):
- 两层叠放(
position: relative容器 + 绝对定位的两张同尺寸 canvas); - 静态层建好内容后渲染一次,之后只在内容变化时置
dirty; - 动画元素放上层;上层默认
setInputPassthrough(true)(需要交互时才关掉); - 两层
ICE.linkViewport;滚轮/拖拽平移在任一层触发都会同步; - 层数 ≤2~3;每层内存 ≈
宽 × 高 × 4B × dpr²(1600×1000 在 dpr=1 约 6.4MB、dpr=2 约 25.6MB); - 拖拽期间把元素提升到动画层、松手放回(Konva drag-layer 模式)用
sourceIce.moveComponentTo(component, animIce)/ 反向迁移 —— 位置与选中态自动保持; 放回前记得清掉临时动画并animationManager.remove()(示例见examples/animation/layered-canvas.html)。 - 导出:矢量用
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/removeAnimation、
manager.replay/isAnimating。时间轴是调度器(把 at 折算成 delay),不是新的求值器 ——
所以前面所有性能机制(写值通道、量化、缓存复用、空闲停帧)对它自动生效。
3.2 写值与重绘解耦(动画专属写值通道)
- 动画写值不得走通用
setState:纯平移(left/top、transform.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 分层渲染(实测单画布 26 |
| 空闲(无动画、无脏帧) | rAF 常驻空转 | 停帧 |
| 既有回归 | jest 全量 + Playwright 82 条 + 逐像素一致性 | 全绿,不许下降 |
性能数字必须可复跑:已固化在
bench/anim/animation-bench.mjs(写值通道/量化,阈值--check) 与bench/layers/layering-bench.mjs(分层渲染),两个门槛都已接进npm run verify:full。 一次性探针脚本一律不入库(结论写进本文,口径与原始数据见 04)。
6. 分期
- 性能地基:动画写值专用通道 + 脏区门控 + 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³) 卡帧缺陷 (聚合预算 + 单测)。 - 分层渲染:静态层 / 动画层(§3.1)。
—— 已落地(2026-09-13):视口绑定 / 输入穿透 / 多实例事件归属 / 跨实例迁移 / 多层导出合成
五个原语 + 应用配方 + 示例;实测 10000 静态元素 + 200 动画标记:26~34ms → 0.4~0.6ms(≈60×),
门槛
npm run bench:layers -- --check(已进verify:full)。 - 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。 - 帧调度与合规:降频 / 空闲停帧 / 拖拽跳过命中 / 屏外裁剪 /
prefers-reduced-motion(§3.4)。 —— 已落地(2026-09-13):按需续帧(静止页面 500ms 内 0 次帧回调)、fps降频、prefers-reduced-motion直接落终态、拖拽跳过命中(回归钉住)、屏外裁剪(既有 + 回归)。 - (另议)渲染后端:万级以上"同时动画"若成为常态,再评估 GPU(WebGL/WebGPU)后端与 OffscreenCanvas, 见 10 · Worker/OffscreenCanvas。
规划内剩余(2026-09-14 盘点):只有 §3.5(可选,未立项)与第 5 条(另议,触发条件未到)。 两项都不是缺陷,而是"要不要为尚未出现的负载提前投资"的取舍 —— 动手前先做性能后端的量化评估。
7. 风险与回滚
- 最大风险是像素一致性:写值通道与脏区门控都直接触碰"脏矩形 / 离屏缓存与全量逐像素一致"这条契约。 每一步都必须带像素回归;一旦不一致,用布尔开关退回旧路径(写值通道与门控都可以做成可回退的常量)。
- 分层会改变坐标系与命中的假设:分层上线前要先明确"层间视口同步、命中归属、导出覆盖范围"三件事。