HarmonyOS 鸿蒙 自定义 Canvas 动效的分层范式 —— Core / Models / Painter / Component 四层解耦与单测
一、先说结论:一个反直觉的发现
做第一个组件 GrokBot 时,我们只是「顺手」把几何抽成了 GrokBotCore.ets。做到第五个、第六个时才发现,每个组件的骨架居然长一个样:
|
组件 |
四层文件 |
|---|---|
|
GrokBot |
|
|
ThinkingOrbs |
|
|
FlowAvatar |
|
|
Coverflow |
|
|
BorderBeam |
|
|
MetalFx |
|
这不是「某种设计模式的套用」,而是被同一个约束逼出来的必然结果:
写在
build()或@Builder里的图形算法,几乎无法单测。
只要你想让组件「可测试、可复用、可跨项目复制」,就会自然地长成这四层。下面拆开讲。
二、四层各是什么
Component(组件) 生命周期、DisplaySync/setInterval、@Prop/@Watch、Canvas 挂载
↓ 调用
Painter(绘制) 只画一帧:把「纯数据」翻译成 Canvas 指令
↓ 依赖
Core(核心) 纯函数:几何、物理、弹簧、投影、缓动、色带、解析
↓ 消费
Models(模型) 枚举 + 值对象:State/Shape/Point/Dot/Config/Options
2.1 Models:纯数据,零逻辑
只放枚举和值对象,不含任何 UI 依赖:
// ThinkingOrbModels.ets 的典型形态
export enum ThinkingOrbState { Working = 0, Searching = 1, /* ... */ }
export class ThinkingOrbDot {
x: number = 0; y: number = 0; z: number = 0;
r: number = 1; white: number = 0.5; a: number = 1;
}
2.2 Core:纯函数,零 UI 依赖
所有「数学」都在这。可单测是它的核心价值:
// FlowAvatarCore.ets 里的纯函数
export function flowAvatarStateSpeed(state: FlowAvatarState): number {
if (state === FlowAvatarState.Thinking) return 2.4;
if (state === FlowAvatarState.Error) return 0.55;
return 1;
}
// GrokBotCore.ets 里的弹簧、投影、缓动
export class GrokBotSpring { /* value/velocity/step() 都是纯数学 */ }
export function grokBotProjectEye(/* ... */): GrokBotProjection { /* 球面投影 */ }
判断标准:Core 里的函数,能不能脱离设备、脱离 Canvas 跑起来?能,就是纯函数;不能,说明 UI 逻辑漏进来了。
2.3 Painter:只画一帧
Painter 依赖 Core + Canvas 2D,但不依赖组件状态、不依赖 timer、不依赖路由。它的输入是一份纯数据(如 FlowAvatarModel / GrokBotPoint[]),输出是 Canvas 指令:
// FlowAvatarPainter.ets
export function paintFlowAvatarFrame(ctx, width, height, model, state,
intensity, edgeDarkness, audioAmplitude, thetaRadians): void {
// 只做:clearRect → 多层 radial fill → 完
}
Painter 本身通常不单测(它要 Canvas 上下文),但因为它极薄——就是把 Core 算好的值 fill/arc/bezier 出来——所以「不测也不怕」,风险全在 Core 里,而 Core 已测。
2.4 Component:只管生命周期和调度
Component 是最「脏」的一层,但它只干三件事:
-
把
@Prop翻译成 Core 的输入(resolvePreset(state, size))。 -
驱动时钟(DisplaySync / setInterval)。
-
挂 Canvas、管
onReady/onVisibleAreaChange/aboutToDisappear。
它不做几何计算——所有几何都委托给 Core,由 Painter 画。
三、每一层的「禁止清单」
这是范式最容易被忽视、也最有价值的部分。ArkUILab 的实验文档里,几乎每个组件的架构表都明确写了「禁止依赖什么」:
|
层 |
可以依赖 |
禁止依赖 |
|---|---|---|
|
Models / Core |
纯逻辑 |
|
|
Painter |
Core + Canvas 2D |
组件状态、timer、路由 |
|
Component |
Core + Painter + DisplaySync |
App 业务路由、AppStorage 品牌键 |
|
Lab / 页面 |
组件 |
改写 Core 内部 |
关键洞察:依赖方向是单向的、向下的。
-
Core 永远不知道「自己被谁用」——它不知道自己在 GrokBot 还是在 ThinkingOrbs 里。
-
Painter 不知道「自己每帧被谁调」——它只知道「给我一份数据和一张画布,我画一帧」。
-
Component 不知道「业务路由」——所以它可以被任何页面复用。
这就是为什么这些组件能整目录复制到别的项目:因为 Core/Models/Painter 三层完全没有任何对「宿主项目」的引用(不碰 AppStorage、不碰全局主题、不碰路由)。
四、一个最小可运行示例
把范式的精华浓缩成一个「会转的圆环」组件,30 行讲清楚:
// ===== Models.ets =====
export enum RingState { Idle = 0, Spinning = 1 }
export class RingParams {
radius: number = 50;
theta: number = 0; // 当前相位
trackColor: string = '#333';
color: string = '#0AF';
}
// ===== Core.ets(纯函数,可单测)=====
export function ringTheta(nowMs: number, speed: number): number {
return (nowMs / 1000) * speed; // 连续相位,不做 t % 1(见 FlowAvatar 篇)
}
export function arcEnd(theta: number): number {
return (theta % (Math.PI * 2)) + Math.PI * 0.3; // 30% 亮弧
}
// ===== Painter.ets(只画一帧)=====
export function paintRing(ctx: CanvasRenderingContext2D, p: RingParams): void {
ctx.clearRect(0, 0, 100, 100);
ctx.strokeStyle = p.trackColor;
ctx.beginPath(); ctx.arc(50, 50, p.radius, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = p.color;
ctx.beginPath(); ctx.arc(50, 50, p.radius, p.theta, arcEnd(p.theta)); ctx.stroke();
}
// ===== Component.ets(只管生命周期 + 时钟)=====
@Component
export struct Ring {
@Prop speed: number = 1;
private ctx = new CanvasRenderingContext2D(new RenderingContextSettings(true));
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval((): void => {
const p = new RingParams();
p.theta = ringTheta(Date.now(), this.speed); // 委托给 Core
paintRing(this.ctx, p); // 委托给 Painter
}, 16);
}
aboutToDisappear(): void { clearInterval(this.timer); }
build() { Canvas(this.ctx).width(100).height(100) }
}
看到没有——组件的 build() 里只有一行 Canvas,没有任何几何。ringTheta 和 arcEnd 是纯函数,可以直接写单测:
expect(ringTheta(2000, 1)).assertEqual(2); // 2 秒 × 1 速度 = 2
expect(arcEnd(0)).assertCloseTo(Math.PI * 0.3, 1e-5);
这就是范式的全部精髓:把「能算的东西」从「能画的东西」里拆出来。
五、为什么「算法写进 build() 就废了」?
这是整个范式成立的根本原因。写进 build() 或 @Builder 的算法,有三个致命伤:
5.1 无法单测
build() 是 ArkUI 生命周期的一部分,只能在真实组件树里跑。你没法「脱离设备」验证 spring.step(dt, ω) 在 300 次迭代后是否收敛到 1——除非把它抽成纯函数。
GrokBot 的测试里就有这么一条,直接锁定弹簧收敛性:
const spring = new GrokBotSpring(); spring.start();
for (let i = 0; i < 300; i++) spring.step(1/120, 7);
expect(near(spring.value, 1, 0.002)).assertTrue(); // 收敛了
expect(spring.active()).assertFalse();
如果这段逻辑埋在组件里,你只能在真机上「肉眼看动画有没有抽搐」。
5.2 无法复用
写在 build() 里的「eye ring 48 点插值」逻辑,和「GrokBot」这个组件死死绑在一起。抽成 resolveDisplayedRings() 纯函数后,任何「从 A 点集到 B 点集做插值」的场景都能用。
5.3 无法对齐上游
这个项目大量移植自 Flutter / Web,「和上游行为一致」这件事只能靠单测锁定。比如 FlowAvatar 的 flowAvatarSeed('user@example.com') === 2085630174,这行测试就是「和 Flutter 逐值对齐」的锚点。写进 build() 里,你永远不知道什么时候悄悄偏离了上游。
六、范式的高级用法:数据文件单独拆
当「数据」本身很大时,四层会进一步长出「数据文件」:
|
组件 |
数据文件 |
内容 |
|---|---|---|
|
GrokBot |
|
25×2×48 眼睛点、18 形状、39 状态 |
|
ThinkingOrbs |
|
6 态 × 2 尺寸的 preset 数值 |
|
BorderBeam |
|
色斑表、尺寸×主题 opacity 表 |
|
MetalFx |
|
3 preset 的 shader 参数 |
这些数据文件仍然是「纯数据」,单测会用不变量守护它们:
// GrokBot.test.ets 的数据不变量
expect(GROKBOT_EXPRESSIONS.length).assertEqual(25);
expect(GROKBOT_EXPRESSIONS[0].length).assertEqual(2); // 两只眼
expect(GROKBOT_EXPRESSIONS[0][0].length).assertEqual(48); // 每眼 48 点
「数据不变量守护」是这层范式的锦上添花——任何人不小心改了 48 点的结构,测试立刻失败。
七、范式什么时候「别用」?
没有银弹。这套四层范式适合**「有实质几何/物理算法 + 要复用 + 要测」**的组件。三种情况别硬套:
-
一次性特效:如果只在一个页面用一次、做完就扔,四层是过度设计,直接写死在
build()里更快。 -
纯布局组件:没有「算法」,只有「堆 Row/Column」的,不需要 Core(Coverflow 虽然有 Core,但它的 Core 是「距离公式」,不是摆设)。
-
性能极度敏感的逐像素效果:这类(如 MetalFx 的 Plasma shader)根本不走 Canvas,得下沉到 Native GPU,四层里的「Painter」会被替换成「GLSL shader + C++ renderer」——范式仍在,只是 Painter 的形态变了。
判断标准一句话:如果你的组件里有「能写单测的纯函数」,就抽 Core;如果所有逻辑都是「摆位置、绑事件」,就别硬拆。
八、总结
这套「Core / Models / Painter / Component」四层范式,是做了十几个鸿蒙动效组件后沉淀出的最底层的共识。它回答了鸿蒙自定义组件开发里反复出现的三个问题:
算法放哪? → Core 纯函数,脱离设备可测。
怎么画? → Painter 极薄一层,只翻译数据为 Canvas 指令。
怎么接线? → Component 只管生命周期 + 时钟,不碰几何。
而这一切的动机,最终收敛到一句话:
写在
build()里的图形算法,几乎无法单测——所以把它们全部抽到纯函数里去。
这套范式不是某个项目的私有约定,而是「可测试性」这个硬约束在 ArkUI 上的自然投影。只要你还想让自己写的动效组件能用单测守护、能整目录复制、能放心地改——它就是你绕不开的那条路。
更多推荐




所有评论(0)