鸿蒙HarmonyOS GrokBot 表情头像实战 —— 从 SVG 到 ArkUI 的零依赖 Canvas 数字人组件
一、前言:鸿蒙里「表情头像」为什么难做对?
先看三个真实场景:
-
AI 对话:模型在思考时,头像应该是「Thinking」的专注表情;回复到一半变成「Working」;答完变成「Happy」。这一组状态切换如果硬编码,会爆炸。
-
语音助手指令:等待唤醒(Listening)、处理中(Thinking)、播报(Speaking),每个阶段头像的眼睛、表情都要有区别。
-
品牌吉祥物:同一只吉祥物要能在多种「躯干形状」(圆、蛋、胶囊、水滴……)之间切换,还要能眨眼、会看向用户手指的方向。
系统组件给不了这些,Web 的 SVG/CSS 动画又无法直接搬进 ArkUI。常见的三个硬伤:
|
问题 |
表现 |
|---|---|
|
用 GIF/Lottie |
状态不可编程,无法「看方向」「锁表情」,深色模式难适配 |
|
在 |
图元一变多就失控,且无法做几何投影(眼睛「越球面转动」) |
|
直接抄 SVG |
ArkUI 的 |
因此 GrokBot 的研究问题变成:
能否在鸿蒙 ArkUI 上做一个可编程、可切换、可测试的表情头像组件,用最少的外部依赖实现「表情/形状/状态/动画」四者解耦?
答案是:可以。关键是把几何、物理、绘制、数据全部拆成纯函数与纯数据,组件本体只负责「生命周期 + 时间驱动」,而不是像 Web 那样把一切塞进一个 <div> 的 CSS。
二、整体架构:五层解耦,组件只做「调度」
2.1 一句话心智模型
数据层(表达式/形状/状态) + 核心层(几何/物理/投影)
↓
渲染层(Canvas 逐帧绘制)
↑
组件层(生命周期 + DisplaySync 高刷调度)
↑
控制层(Controller: blink / spin / reset)
GrokBot 拆成六个文件,各司其职:
|
文件 |
职责 |
关键内容 |
|---|---|---|
|
|
ArkUI 组件 + 生命周期 + DisplaySync 调度 |
|
|
|
纯函数几何/物理 |
投影、圆角解析、弹簧、缓动 |
|
|
Canvas 绘制 |
轮廓 path、眼睛 ring 渲染 |
|
|
枚举 + 数据模型 |
39 状态、18 形状、25 表情 |
|
|
表情数据 |
25 × 2 眼 × 48 点 |
|
|
形状数据 |
18 种躯干几何参数 |
|
|
状态数据 |
39 状态的表情子集 + 节奏 |
|
|
一次性动画控制 |
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 |
触发动作 |
|---|---|---|
|
|
|
重新选择表情、重排眨眼/表情定时器 |
|
|
|
只重排定时器,不改视觉 |
|
|
|
仅 |
|
|
|
仅重绘(尺寸变了) |
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 变了才走 onExpressionSourceChanged 去 selectExpression。
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);
// ...
}
这个设计有两个好处:
-
不会被误用:把同一个 controller 塞给两个 GrokBot,第二个会直接
throw,把隐患暴露在开发期而不是运行期。 -
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表示「这个状态不眨眼」——Sleeping、Drowsy、Orbit等状态眼睛一直闭着或保持,不触发眨眼定时器。 -
表情切换节奏与眨眼节奏分离:
expressionCadence管「换表情」,blinkCadence管「眨眼」,两者独立随机,互不干扰。 -
状态越「兴奋」节奏越短:
Searching1~1.8 秒换一次表情,Sleeping6~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.dark 在 light() / 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 高刷绘制》的范畴。
更多推荐




所有评论(0)