
一、前言鸿蒙里「表情头像」为什么难做对先看三个真实场景AI 对话模型在思考时头像应该是「Thinking」的专注表情回复到一半变成「Working」答完变成「Happy」。这一组状态切换如果硬编码会爆炸。语音助手指令等待唤醒Listening、处理中Thinking、播报Speaking每个阶段头像的眼睛、表情都要有区别。品牌吉祥物同一只吉祥物要能在多种「躯干形状」圆、蛋、胶囊、水滴……之间切换还要能眨眼、会看向用户手指的方向。系统组件给不了这些Web 的 SVG/CSS 动画又无法直接搬进 ArkUI。常见的三个硬伤问题表现用 GIF/Lottie状态不可编程无法「看方向」「锁表情」深色模式难适配在build()里堆animateTo图元一变多就失控且无法做几何投影眼睛「越球面转动」直接抄 SVGpathArkUI 的Path/Shape与 SVG 路径语义、坐标系、缩放不完全对等逐字迁移会变形因此 GrokBot 的研究问题变成能否在鸿蒙 ArkUI 上做一个可编程、可切换、可测试的表情头像组件用最少的外部依赖实现「表情/形状/状态/动画」四者解耦答案是可以。关键是把几何、物理、绘制、数据全部拆成纯函数与纯数据组件本体只负责「生命周期 时间驱动」而不是像 Web 那样把一切塞进一个div的 CSS。二、整体架构五层解耦组件只做「调度」2.1 一句话心智模型数据层(表达式/形状/状态) 核心层(几何/物理/投影) ↓ 渲染层(Canvas 逐帧绘制) ↑ 组件层(生命周期 DisplaySync 高刷调度) ↑ 控制层(Controller: blink / spin / reset)GrokBot 拆成六个文件各司其职文件职责关键内容GrokBot.etsArkUI 组件 生命周期 DisplaySync 调度PropWatch、onFrame循环GrokBotCore.ets纯函数几何/物理投影、圆角解析、弹簧、缓动GrokBotPainter.etsCanvas 绘制轮廓 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: ArrayGrokBotExpression; // 该状态允许的表情子集 expressionCadence: GrokBotCadence; // 表情切换节奏毫秒区间 blinkCadence: GrokBotCadence | null; // 眨眼节奏null 表示不眨眼 }关键设计状态和表情是两套正交的抽象。Expression表情眼睛 ring 的确切几何形状是「底层图元」。State状态一组expressions 两条 cadence切换节奏 眨眼节奏是「上层语义」。比如Thinking状态映射到Expression08 / 16 / 14 / 17 / 05这组眼睛形状并在 3.57 秒随机切换一次、23.6 秒眨一次眼。这种「状态 表情集合 节奏」的映射让「加一个新情绪」不再需要改渲染代码只需要在GrokBotStateData.ets加一行。三、数据本质眼睛是「48 个点的闭合环」3.1 表情不是图片是点的数组这是 GrokBot 最核心的抽象。上游 SVG 里眼睛是一个 path移植到 ArkUI 后它被采样成 48 个点的闭合多边形// GrokBotExpressionData.ets25 套表情 × 2 只眼 × 48 个点 export const GROKBOT_EXPRESSIONS: ArrayArrayArrayGrokBotPoint [ [ // 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 无关、跨设备一致的关键。四、组件数据流PropWatch的声明式驱动4.1 公开 APIGrokBot 的 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触发动作onExpressionSourceChangedstate、expression重新选择表情、重排眨眼/表情定时器onSchedulingChangedautoBlink、autoExpression只重排定时器不改视觉onVisualChangedshape/gaze/turn/eyeScale/flipX/emphasis/showGuides/theme/springFrequency仅paintNow()重绘一帧onSizeChangedbotSize仅重绘尺寸变了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.3expression 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: ArrayArrayGrokBotPoint []; // 起始表情 private targetRings: ArrayArrayGrokBotPoint []; // 目标表情 private displayRings: ArrayArrayGrokBotPoint []; // 当前显示的表情 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 弹到 148 个点各自做线性插值。由于两只眼的点一一对应都是 48 点、顺序一致插值后依然是光滑的眼睛形状——这需要一个前提所有表情的点集拓扑一致都是同一个闭合环GrokBot 的数据正是严格保证这一点。5.2 为什么用三 buffer 而不是两 buffer如果只有两 buffer当前 目标一旦用户在动画中途又改了表情就会「从半路起点直接跳到新目标」出现跳变。三 buffer 的做法是每次切换前先把displayRings当前实际显示值冻结成sourceRings再设新的targetRings弹簧从中间态重新启动——这样无论连续切换多少次都平滑。六、控制器一次只控一个 GrokBot6.1 为什么需要 ControllerProp只能做「持续状态」长期处于 Thinking但「眨眼一下」「转一圈」是一次性瞬态动画。如果靠 props 传标志位会很别扭。GrokBot 用GrokBotController提供命令式的一次性动作export class GrokBotControllerBinding { blink: () Promisevoid (): Promisevoid Promise.resolve(); spin: (turns: number, duration: number) Promisevoid /* ... */; reset: () Promisevoid (): Promisevoid 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(): Promisevoid { /* 委托给 binding.blink() */ } spin(turns: number 1, duration: number 1200): Promisevoid { /* ... */ } reset(): Promisevoid { /* ... */ } }使用方式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。组件在aboutToAppearattach、aboutToDisappeardetachaboutToAppear(): void { // ... this.binding.blink (): Promisevoid this.beginBlink(); this.binding.spin (turns, duration) this.beginSpin(turns, duration); this.binding.reset (): Promisevoid 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(): Promisevoid { this.completeBlink(); this.blinkSeconds 0; this.startAnimation(); this.paintNow(); return new Promisevoid((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: MapGrokBotState, 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管「眨眼」两者独立随机互不干扰。状态越「兴奋」节奏越短Searching11.8 秒换一次表情Sleeping610 秒才换一次语义自然。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 高刷绘制》的范畴。