一、先说结论:一个反直觉的发现

做第一个组件 GrokBot 时,我们只是「顺手」把几何抽成了 GrokBotCore.ets。做到第五个、第六个时才发现,每个组件的骨架居然长一个样

组件

四层文件

GrokBot

GrokBot / GrokBotCore / GrokBotModels / GrokBotPainter

ThinkingOrbs

ThinkingOrb / ThinkingOrbCore / ThinkingOrbModels / ThinkingOrbPainter

FlowAvatar

FlowAvatar / FlowAvatarCore / FlowAvatarModels / FlowAvatarPainter

Coverflow

CoverflowCarousel / CoverflowCore / CoverflowModels / (几何直接进 Core)

BorderBeam

BorderBeam / BorderBeamCore / BorderBeamModels / BorderBeamPainter

MetalFx

MetalFx / MetalFxCore / MetalFxModels / MetalFxPresets

这不是「某种设计模式的套用」,而是被同一个约束逼出来的必然结果

写在 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 是最「脏」的一层,但它只干三件事:

  1. @Prop 翻译成 Core 的输入resolvePreset(state, size))。

  2. 驱动时钟(DisplaySync / setInterval)。

  3. 挂 Canvas、管 onReady/onVisibleAreaChange/aboutToDisappear

不做几何计算——所有几何都委托给 Core,由 Painter 画。


三、每一层的「禁止清单」

这是范式最容易被忽视、也最有价值的部分。ArkUILab 的实验文档里,几乎每个组件的架构表都明确写了「禁止依赖什么」:

可以依赖

禁止依赖

Models / Core

纯逻辑

@Component、Navigation、ThemeEngine

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,没有任何几何ringThetaarcEnd 是纯函数,可以直接写单测:

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

GrokBotExpressionData / ShapeData / StateData

25×2×48 眼睛点、18 形状、39 状态

ThinkingOrbs

ThinkingOrbProfiles

6 态 × 2 尺寸的 preset 数值

BorderBeam

BorderBeamPalettes / Profiles

色斑表、尺寸×主题 opacity 表

MetalFx

MetalFxPresets

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 点的结构,测试立刻失败。


七、范式什么时候「别用」?

没有银弹。这套四层范式适合**「有实质几何/物理算法 + 要复用 + 要测」**的组件。三种情况别硬套:

  1. 一次性特效:如果只在一个页面用一次、做完就扔,四层是过度设计,直接写死在 build() 里更快。

  2. 纯布局组件:没有「算法」,只有「堆 Row/Column」的,不需要 Core(Coverflow 虽然有 Core,但它的 Core 是「距离公式」,不是摆设)。

  3. 性能极度敏感的逐像素效果:这类(如 MetalFx 的 Plasma shader)根本不走 Canvas,得下沉到 Native GPU,四层里的「Painter」会被替换成「GLSL shader + C++ renderer」——范式仍在,只是 Painter 的形态变了。

判断标准一句话:如果你的组件里有「能写单测的纯函数」,就抽 Core;如果所有逻辑都是「摆位置、绑事件」,就别硬拆。


八、总结

这套「Core / Models / Painter / Component」四层范式,是做了十几个鸿蒙动效组件后沉淀出的最底层的共识。它回答了鸿蒙自定义组件开发里反复出现的三个问题:

算法放哪? → Core 纯函数,脱离设备可测。
怎么画? → Painter 极薄一层,只翻译数据为 Canvas 指令。
怎么接线? → Component 只管生命周期 + 时钟,不碰几何。

而这一切的动机,最终收敛到一句话:

写在 build() 里的图形算法,几乎无法单测——所以把它们全部抽到纯函数里去。

这套范式不是某个项目的私有约定,而是「可测试性」这个硬约束在 ArkUI 上的自然投影。只要你还想让自己写的动效组件能用单测守护、能整目录复制、能放心地改——它就是你绕不开的那条路。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐