一、前言:鸿蒙里「表情头像」为什么难做对?

先看三个真实场景:

  1. AI 对话:模型在思考时,头像应该是「Thinking」的专注表情;回复到一半变成「Working」;答完变成「Happy」。这一组状态切换如果硬编码,会爆炸。

  2. 语音助手指令:等待唤醒(Listening)、处理中(Thinking)、播报(Speaking),每个阶段头像的眼睛、表情都要有区别。

  3. 品牌吉祥物:同一只吉祥物要能在多种「躯干形状」(圆、蛋、胶囊、水滴……)之间切换,还要能眨眼、会看向用户手指的方向。

系统组件给不了这些,Web 的 SVG/CSS 动画又无法直接搬进 ArkUI。常见的三个硬伤:

问题

表现

用 GIF/Lottie

状态不可编程,无法「看方向」「锁表情」,深色模式难适配

build() 里堆 animateTo

图元一变多就失控,且无法做几何投影(眼睛「越球面转动」)

直接抄 SVG path

ArkUI 的 Path/Shape 与 SVG 路径语义、坐标系、缩放不完全对等,逐字迁移会变形

因此 GrokBot 的研究问题变成:

能否在鸿蒙 ArkUI 上做一个可编程、可切换、可测试的表情头像组件,用最少的外部依赖实现「表情/形状/状态/动画」四者解耦?

答案是:可以。关键是把几何、物理、绘制、数据全部拆成纯函数与纯数据,组件本体只负责「生命周期 + 时间驱动」,而不是像 Web 那样把一切塞进一个 <div> 的 CSS。


二、整体架构:五层解耦,组件只做「调度」

2.1 一句话心智模型

数据层(表达式/形状/状态)  +  核心层(几何/物理/投影)
        ↓
渲染层(Canvas 逐帧绘制)
        ↑
组件层(生命周期 + DisplaySync 高刷调度)
        ↑
控制层(Controller: blink / spin / reset)

GrokBot 拆成六个文件,各司其职:

文件

职责

关键内容

GrokBot.ets

ArkUI 组件 + 生命周期 + DisplaySync 调度

@Prop + @WatchonFrame 循环

GrokBotCore.ets

纯函数几何/物理

投影、圆角解析、弹簧、缓动

GrokBotPainter.ets

Canvas 绘制

轮廓 path、眼睛 ring 渲染

GrokBotModels.ets

枚举 + 数据模型

39 状态、18 形状、25 表情

GrokBotExpressionData.ets

表情数据

25 × 2 眼 × 48 点

GrokBotShapeData.ets

形状数据

18 种躯干几何参数

GrokBotStateData.ets

状态数据

39 状态的表情子集 + 节奏

GrokBotController.ets

一次性动画控制

blink / spin / reset

为什么分层:图形算法一旦写进 build()@Builder 就几乎无法单测。GrokBot 把核心几何、弹簧、投影全部抽成纯函数后,可以用 @ohos/hypium 在无设备环境直接锁定行为(见 GrokBot.test.ets)。

2.2 数据模型:一切都是「纯数据」

GrokBotModels.ets 里没有任何 UI 逻辑,只有枚举和值对象:

export enum GrokBotState {
  Sleeping = 'sleeping', Waking = 'waking', Idle = 'idle', Listening = 'listening',
  Thinking = 'thinking', Searching = 'searching', Working = 'working', Excited = 'excited',
  // ...共 39 个状态,含 Orbit / Radar / Progress / Spawning / Dragging 等交互态
}

export enum GrokBotShape {
  Blob = 'blob', Pebble = 'pebble', Bean = 'bean', Egg = 'egg', Squircle = 'squircle',
  Tablet = 'tablet', Capsule = 'capsule', Cylinder = 'cylinder', Hex = 'hex', Gem = 'gem',
  // ...共 18 个形状
}

export enum GrokBotExpression { Expression00 = 0, Expression01, /* ... Expression24 */ }

export class GrokBotPoint { x: number; y: number; }
export class GrokBotGaze { x: number; y: number; }
export class GrokBotStateData {
  expressions: Array<GrokBotExpression>;   // 该状态允许的表情子集
  expressionCadence: GrokBotCadence;       // 表情切换节奏(毫秒区间)
  blinkCadence: GrokBotCadence | null;     // 眨眼节奏,null 表示不眨眼
}

关键设计:状态和表情是两套正交的抽象

  • Expression(表情):眼睛 ring 的确切几何形状,是「底层图元」。

  • State(状态):一组 expressions + 两条 cadence(切换节奏 + 眨眼节奏),是「上层语义」。

比如 Thinking 状态映射到 Expression08 / 16 / 14 / 17 / 05 这组眼睛形状,并在 3.5~7 秒随机切换一次、2~3.6 秒眨一次眼。这种「状态 = 表情集合 + 节奏」的映射,让「加一个新情绪」不再需要改渲染代码,只需要在 GrokBotStateData.ets 加一行。


三、数据本质:眼睛是「48 个点的闭合环」

3.1 表情不是图片,是点的数组

这是 GrokBot 最核心的抽象。上游 SVG 里眼睛是一个 path,移植到 ArkUI 后,它被采样成 48 个点的闭合多边形

// GrokBotExpressionData.ets:25 套表情 × 2 只眼 × 48 个点
export const GROKBOT_EXPRESSIONS: Array<Array<Array<GrokBotPoint>>> = [
  [ // Expression00 : 两只眼各 48 点
    [new GrokBotPoint(130.36, 45.98), new GrokBotPoint(132.71, 46.19), /* ... 48 个点 */],
    [new GrokBotPoint(176.61, 37.08), new GrokBotPoint(178.72, 37.59), /* ... 48 个点 */]
  ],
  // ... Expression01 ~ Expression24
];

GrokBotPoint 就是 {x, y},没有贝塞尔曲线、没有 path 命令、没有 SVG 特有的 C/Q因为点足够密(48 点),把它直接 moveTo + lineTo + closePath 填色,视觉上就是光滑的眼睛——这是从「几何精确」到「渲染简单」的一次关键取舍。

3.2 坐标系:统一到 viewBox

所有点坐标都落在同一个 259 × 259 的 viewBox 空间里:

export const GROKBOT_VIEWBOX_SIZE: number = 259;   // 逻辑坐标系边长
export const GROKBOT_VIEWBOX_INSET: number = 15;   // 四周留白
export const GROKBOT_BODY_WIDTH: number = 228.541; // 实际躯干宽度
export const GROKBOT_FACE_CENTER: number = 114.2705; // 脸部中心 = 228.541 / 2

这一层「逻辑坐标 → 物理像素」的映射放在 paintGrokBotFrame 里统一做(第二篇会展开)。数据层只管在 259 空间里写点,不关心设备分辨率、不关心 vp/px,这是 DPR 无关、跨设备一致的关键。


四、组件数据流:@Prop + @Watch 的声明式驱动

4.1 公开 API

GrokBot 的 props 全部用 @Prop(父传子单向)+ @Watch(监听变化):

@Component
export struct GrokBot {
  @Watch('onExpressionSourceChanged') @Prop state: GrokBotState = GrokBotState.Idle;
  @Watch('onVisualChanged') @Prop shape: GrokBotShape = GrokBotShape.Blob;
  @Watch('onExpressionSourceChanged') @Prop expression: GrokBotExpression | null = null;
  @Watch('onVisualChanged') @Prop gaze: GrokBotGaze = new GrokBotGaze();
  @Watch('onVisualChanged') @Prop turn: number = 0;
  @Watch('onVisualChanged') @Prop eyeScale: number = 1;
  @Watch('onVisualChanged') @Prop springFrequency: number = 7;
  @Watch('onSchedulingChanged') @Prop autoBlink: boolean = true;
  @Watch('onSchedulingChanged') @Prop autoExpression: boolean = false;
  @Watch('onVisualChanged') @Prop flipX: boolean = false;
  @Watch('onVisualChanged') @Prop emphasis: boolean = false;
  @Watch('onVisualChanged') @Prop showGuides: boolean = false;
  @Watch('onSizeChanged') @Prop botSize: number = 228.541;
  @Watch('onVisualChanged') @Prop theme: GrokBotThemeData = GrokBotThemeData.light();
  @Prop label: string = 'GrokBot';
  controller: GrokBotController | null = null;
}

最小接入(来自 Lab 页的 Documentation 区):

GrokBot({
  state: GrokBotState.Thinking,
  shape: GrokBotShape.Blob,
  gaze: new GrokBotGaze(0.3, -0.1),
  autoBlink: true,
  autoExpression: true
})

4.2 三种 @Watch 回调:把「什么变了」分类

观察点:GrokBot 把所有 watcher 分成三类,每类处理逻辑不同,这是比「一刀切重绘」更精细的地方:

回调

绑定的 props

触发动作

onExpressionSourceChanged

stateexpression

重新选择表情、重排眨眼/表情定时器

onSchedulingChanged

autoBlinkautoExpression

只重排定时器,不改视觉

onVisualChanged

shape/gaze/turn/eyeScale/flipX/emphasis/showGuides/theme/springFrequency

paintNow() 重绘一帧

onSizeChanged

botSize

仅重绘(尺寸变了)

private onExpressionSourceChanged(): void {
  if (!this.appeared) return;
  const desired = this.expression === null ? this.stateData().expressions[0] : this.expression;
  if (desired !== this.currentExpression) this.selectExpression(desired, true);
  this.scheduleBlink(); this.scheduleExpression(); this.paintNow();
}

private onVisualChanged(): void { if (this.appeared) this.paintNow(); }
private onSchedulingChanged(): void {
  if (!this.appeared) return;
  this.scheduleBlink(); this.scheduleExpression();
}

要点gaze 这类「只改视觉、不改表情」的 props,走 onVisualChanged 直接一帧重绘,不会误触发表情切换的 Spring 动画;而 state 变了才走 onExpressionSourceChangedselectExpression

4.3 expression === null 的语义:自动 vs 锁定

expression 是可空类型,这是整个数据流的枢纽:

  • expression === null:跟随 state,从该状态的表情子集里自动随机切换表情(autoExpression 控制)。

  • expression !== null:锁定到某个表情,autoExpression 失效(Lab 里的「Fixed Expression」滑块就是这个用途)。

// selectExpression 的核心分支
if (animate) {
  this.resolveDisplayedRings(); this.copyRings(this.displayRings, this.sourceRings);
  this.copyRings(GROKBOT_EXPRESSIONS[expression], this.targetRings);
  this.spring.start(); this.currentExpression = expression; this.startAnimation();
} else {
  // 直接硬切,不经过弹簧动画
  this.copyRings(GROKBOT_EXPRESSIONS[expression], this.displayRings);
  this.spring.value = 1; this.spring.velocity = 0;
}

五、三环形 buffer:表情切换的「从 A 到 B」插值

5.1 source / target / display 三段

GrokBot 内部维护三组眼睛点数据,用来实现表情之间的平滑过渡

private sourceRings: Array<Array<GrokBotPoint>> = [];   // 起始表情
private targetRings: Array<Array<GrokBotPoint>> = [];   // 目标表情
private displayRings: Array<Array<GrokBotPoint>> = [];  // 当前显示的表情
private spring: GrokBotSpring = new GrokBotSpring();

切换表情时:把当前 displayRings 拷贝进 sourceRings,把目标表情拷贝进 targetRings,然后启动弹簧。每一帧:

private resolveDisplayedRings(): void {
  const amount: number = Math.max(0, Math.min(1, this.spring.value));
  for (let eye = 0; eye < 2; eye++) for (let point = 0; point < 48; point++) {
    const from = this.sourceRings[eye][point];
    const to = this.targetRings[eye][point];
    this.displayRings[eye][point].x = from.x + (to.x - from.x) * amount;
    this.displayRings[eye][point].y = from.y + (to.y - from.y) * amount;
  }
}

spring.value 从 0 弹到 1,48 个点各自做线性插值。由于两只眼的点一一对应(都是 48 点、顺序一致),插值后依然是光滑的眼睛形状——这需要一个前提:所有表情的点集拓扑一致(都是同一个闭合环),GrokBot 的数据正是严格保证这一点。

5.2 为什么用三 buffer 而不是两 buffer

如果只有两 buffer(当前 + 目标),一旦用户在动画中途又改了表情,就会「从半路起点直接跳到新目标」,出现跳变。三 buffer 的做法是:每次切换前先把 displayRings(当前实际显示值)冻结成 sourceRings,再设新的 targetRings,弹簧从中间态重新启动——这样无论连续切换多少次都平滑。


六、控制器:一次只控一个 GrokBot

6.1 为什么需要 Controller

@Prop 只能做「持续状态」(长期处于 Thinking),但「眨眼一下」「转一圈」是一次性瞬态动画。如果靠 props 传标志位会很别扭。GrokBot 用 GrokBotController 提供命令式的一次性动作:

export class GrokBotControllerBinding {
  blink: () => Promise<void> = (): Promise<void> => Promise.resolve();
  spin: (turns: number, duration: number) => Promise<void> = /* ... */;
  reset: () => Promise<void> = (): Promise<void> => Promise.resolve();
}

export class GrokBotController {
  private binding: GrokBotControllerBinding | null = null;
  attach(binding: GrokBotControllerBinding): void {
    if (this.binding !== null && this.binding !== binding) {
      throw new Error('A GrokBotController can only control one GrokBot at a time.');
    }
    this.binding = binding;
  }
  blink(): Promise<void> { /* 委托给 binding.blink() */ }
  spin(turns: number = 1, duration: number = 1200): Promise<void> { /* ... */ }
  reset(): Promise<void> { /* ... */ }
}

使用方式(Lab 页 Hero 区):

private controller: GrokBotController = new GrokBotController();
// ...
GrokBot({ controller: this.controller, state: this.state, /* ... */ })
// ...
Button('Blink').onClick(() => { this.controller.blink(); })
Button('Spin').onClick(() => { this.controller.spin(); })
Button('Reset').onClick(() => { this.controller.reset(); })

6.2 一比一绑定约束

关键约束:一个 Controller 同一时刻只能 attach 一个 GrokBot。组件在 aboutToAppear attach、aboutToDisappear detach:

aboutToAppear(): void {
  // ...
  this.binding.blink = (): Promise<void> => this.beginBlink();
  this.binding.spin = (turns, duration) => this.beginSpin(turns, duration);
  this.binding.reset = (): Promise<void> => this.resetTransient();
  if (this.controller !== null) this.controller.attach(this.binding);
}
aboutToDisappear(): void {
  if (this.controller !== null) this.controller.detach(this.binding);
  // ...
}

这个设计有两个好处:

  1. 不会被误用:把同一个 controller 塞给两个 GrokBot,第二个会直接 throw,把隐患暴露在开发期而不是运行期。

  2. Promise 语义清晰blink() 返回的 Promise 在眨眼完成(0.32 秒)时 resolve,调用方可以 await controller.blink() 做链式编排。

6.3 眨眼、Spin 完成态的 resolve 管理

瞬态动画的完成回调用「可空 resolve」字段 + complete 方法管理:

private blinkResolve: (() => void) | null = null;
private spinResolve: (() => void) | null = null;

private beginBlink(): Promise<void> {
  this.completeBlink(); this.blinkSeconds = 0; this.startAnimation(); this.paintNow();
  return new Promise<void>((resolve) => { this.blinkResolve = resolve; });
}
private completeBlink(): void {
  if (this.blinkResolve !== null) this.blinkResolve();
  this.blinkResolve = null;
}

aboutToDisappear 时会 completeBlink() / completeSpin(),确保组件销毁时挂起的 Promise 不会泄漏。


七、状态数据:39 个状态的一张「节奏表」

GrokBotStateData.ets 是一张巨大的映射表,把每个状态映射到「表情子集 + 两条 cadence」。这是「情绪丰富」却「代码简单」的真相:

export const GROKBOT_STATES: Map<GrokBotState, GrokBotStateData> = new Map([
  [GrokBotState.Sleeping,
    new GrokBotStateData([Expression13, Expression22, Expression04],
      new GrokBotCadence(6000, 10000), null)],   // blinkCadence = null → 闭眼不眨
  [GrokBotState.Idle,
    new GrokBotStateData([Expression00, Expression08],
      new GrokBotCadence(9000, 16000), new GrokBotCadence(6000, 14000))],
  [GrokBotState.Thinking,
    new GrokBotStateData([Expression08, Expression16, Expression14, Expression17, Expression05],
      new GrokBotCadence(2000, 3600), new GrokBotCadence(3500, 7000))],
  [GrokBotState.Searching,
    new GrokBotStateData([Expression15, Expression09, Expression03, Expression20, Expression12, Expression18],
      new GrokBotCadence(1000, 1800), new GrokBotCadence(1600, 4000))],
  // ... 共 39 个
]);

几个值得注意的细节:

  • blinkCadence = null 表示「这个状态不眨眼」——SleepingDrowsyOrbit 等状态眼睛一直闭着或保持,不触发眨眼定时器。

  • 表情切换节奏与眨眼节奏分离expressionCadence 管「换表情」,blinkCadence 管「眨眼」,两者独立随机,互不干扰。

  • 状态越「兴奋」节奏越短Searching 1~1.8 秒换一次表情,Sleeping 6~10 秒才换一次,语义自然。

7.1 随机节奏的生成

GrokBotCadence 只存 minimum / maximum 两个毫秒值,实际间隔由 randomDuration 生成:

private randomDuration(minimum: number, maximum: number): number {
  return minimum + Math.floor(Math.random() * (Math.max(minimum, maximum) - minimum + 1));
}
private scheduleExpression(): void {
  if (this.expressionTimer >= 0) { clearTimeout(this.expressionTimer); this.expressionTimer = -1; }
  if (!this.appeared || !this.visible || !this.autoExpression || this.expression !== null) return;
  const data = this.stateData();
  this.expressionTimer = setTimeout(() => {
    const alternatives = data.expressions.filter(item => item !== this.currentExpression);
    const next = alternatives.length === 0 ? data.expressions[0]
      : alternatives[Math.floor(Math.random() * alternatives.length)];
    this.selectExpression(next, true); this.scheduleExpression();
  }, this.randomDuration(data.expressionCadence.minimum, data.expressionCadence.maximum));
}

注意:随机切换会排除当前表情filter(item => item !== currentExpression)),避免「换了个寂寞」。


八、主题:深色/浅色一套数据两种配色

GrokBotThemeData 也是数据层的一部分:

export class GrokBotThemeData {
  bodyColor: string; eyeColor: string; guideColor: string;
  centroidColor: string; badgeColor: string; particleColor: string;
  static light(): GrokBotThemeData {
    return new GrokBotThemeData(); // 默认 #5B7FE5 身体、#FFFDF7 眼睛
  }
  static dark(): GrokBotThemeData {
    return new GrokBotThemeData('#6689EA', '#181A15', '#A5A89D', '#FF8B5E', '#6689EA', '#FF8B5E');
  }
}

主题把「身体色、眼睛色、引导线色、质心色、徽章色、粒子色」全部数据化,Lab 页通过 this.darklight() / dark() 间切换,配合 HDS 标题栏的局部明暗(第二篇不展开,但注意:深色头像要配深色眼睛 #181A15,而不是继续用 #FFFDF7 白眼睛,否则对比度会崩)。


九、测试:数据与算法可以完全脱离 UI 单测

因为分层干净,GrokBot.test.ets@ohos/hypium 锁定了核心契约,全程不依赖 UI 树:

describe('GrokBotData', () => {
  it('containsCompleteExpressionsShapesAndStates', 0, () => {
    expect(GROKBOT_EXPRESSIONS.length).assertEqual(25);
    // 每个表情 = 2 只眼,每只眼 48 点
    expect(GROKBOT_EXPRESSIONS[0].length).assertEqual(2);
    expect(GROKBOT_EXPRESSIONS[0][0].length).assertEqual(48);
    expect(GROKBOT_SHAPES.size).assertEqual(18);
    expect(GROKBOT_STATES.size).assertEqual(39);
  });
});

这份单测本身也是一个「数据不变量」的守护者:任何人不小心改了 48 点的结构,测试会立刻失败。


十、接入清单(可直接当 PR Review Checklist)

  • 复制 grokbot/ 目录(8 个 .ets 文件),保持同目录与相对 import。

  • GrokBot({ state, shape, gaze, autoBlink, autoExpression }) 接入,默认 Idle + Blob。

  • 需要一次性动作时,new 一个 GrokBotController 传给 controller,用 blink()/spin()/reset()

  • 一个 controller 只接一个 GrokBot;销毁时组件会 detach,勿手动复用。

  • 深色场景传 theme: GrokBotThemeData.dark(),不要用默认白眼睛。

  • 锁定表情时传 expression(非 null);自动表情时传 null 或省略。

  • 无障碍文案走 label,不要留默认 'GrokBot'

  • 改动表情/形状/状态数据后,跑一次 containsCompleteExpressionsShapesAndStates 守护不变量。


十一、总结

GrokBot 的第一篇讲了「骨架」:它是怎么用纯数据 + 纯函数,把一个复杂的表情头像系统,收敛成几个可测试、可组合的模块的。核心结论:

表情是点集,状态是点集的集合 + 节奏。
组件只做调度,几何物理全部纯函数化。
@Prop 管持续状态,Controller 管一次性动画。
三环形 buffer + 弹簧,让表情切换永远平滑。
数据不变量用单测守护,而不是靠人肉 review。

但这一篇还没回答最「精彩」的部分:眼睛为什么能在躯干球面上转动?眨眼为什么有 0.32 秒的节奏?DisplaySync 怎么做到 60fps 逐帧又不空转? 这些问题涉及投影几何、弹簧物理和高刷调度,属于第二篇《GrokBot 渲染引擎:投影几何、弹簧物理与 DisplaySync 高刷绘制》的范畴。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐