一、前言:默认头像的「确定性」难题

先看两个真实痛点:

  1. 首字母头像王小明王小强 都是「王」,撞色撞形,毫无辨识度。

  2. 随机头像:每次启动都不一样,用户找不到「我的那个头像」,跨设备也不一致。

flow_avatar 的思路是:让「身份」变成「确定性」的。核心公式:

seed (字符串,如 user@example.com)
  → flowAvatarSeed (FNV-1a 哈希 → uint32)
  → SeededRandom (xorshift PRNG)
  → FlowAvatarModel (调色板、色斑、背景、高光)
       ↓
state + speed + intensity + audioAmplitude + 连续相位
  → MotionStyle
  → Canvas 多层 radial 叠绘
  → 裁剪成头像

同一 seed → 同一 PRNG 序列 → 同一组色斑位置/半径/相位 → 同一个头像,永远可复现。这就是「视觉身份」的含义。


二、确定性哈希:字符串 → uint32,且对齐 Flutter 语义

2.1 为什么不能用 JS 默认的 hashCode

flowAvatarSeedFNV-1a 哈希,这是整个「确定性」的根基:

export function flowAvatarSeed(value: string): number {
  let hash: number = 0x811c9dc5;              // FNV-1a 初始偏移
  for (let i: number = 0; i < value.length; i++) {
    hash ^= value.charCodeAt(i);             // UTF-16 code unit(对齐 Dart/JS)
    hash = imul32(hash, 0x01000193);         // FNV prime,32 位乘法
  }
  hash ^= hash >>> 16;
  hash = imul32(hash, 0x7feb352d);           // 后续 avalanche 混合
  hash ^= hash >>> 15;
  hash = imul32(hash, 0x846ca68b);
  hash ^= hash >>> 16;
  return toUint32(hash);
}

两个关键细节:

  1. charCodeAt(UTF-16 code unit):注释明确写了「Iterates UTF-16 code units to match Dart/JS semantics」——Dart 的字符串是 UTF-16 编码,用 charCodeAt 才能和 Flutter 上游得到逐字节一致的结果(否则中文、emoji 会算出不同的 seed)。

  2. imul32(32 位整数乘法):JS 的 number 是 64 位浮点,直接 a * b 会溢出精度;imul32 手动拆高低 16 位相乘,保证得到和 Dart 的 uint32 乘法完全一致的低 32 位结果。

2.2 测试锁定的 fixture

单测里锁定了和 Flutter 逐值对齐的结果:

flowAvatarSeed('user@example.com')  === 2085630174
flowAvatarSeed('你好 👋')         === 513115367
flowAvatarSeed('user-1001') !== flowAvatarSeed('user-1002')

这行 expect(first).assertEqual(2085630174) 是整个「确定性」的锚点——任何人不小心改了哈希实现,测试立刻失败,从而保证「跨平台身份一致」。

2.3 xorshift PRNG:让「随机」可复现

有了 seed,还要把它变成一连串「看起来随机、但可复现」的数。SeededRandom 用 xorshift 算法:

export class SeededRandom {
  private state: number;
  constructor(seed: number) {
    this.state = seed === 0 ? 0x6d2b79f5 : toUint32(seed);  // 0 要换成非零种子
  }
  nextUint32(): number {
    let value: number = this.state;
    value ^= value << 13;
    value ^= value >>> 17;
    value ^= value << 5;
    this.state = toUint32(value);
    return this.state;
  }
  nextDouble(): number { return this.nextUint32() / 0x100000000; }  // [0,1)
  nextInt(max: number): number { return Math.floor(this.nextDouble() * max); }
  between(min: number, max: number): number {
    return min + this.nextDouble() * (max - min);
  }
}

要点:同一种子生成同一条序列。模型构建时,所有「随机」——色斑的数量、位置、半径、相位、颜色索引——都从这条确定序列里取。所以「随机」只是表象,本质是 seed 的确定性展开。


三、模型生成:seed 定形,baseColor 定色

3.1 一个「身份」模型里有什么

export class FlowAvatarModel {
  seed: number = 0;
  colors: Array<string> = [];        // 5 色调色板
  background: string = '#000000';
  spots: Array<FlowAvatarSpot> = []; // 9~12 个 soft blob
  highlightX: number = 0.25;         // 高光位置
  highlightY: number = 0.2;
}

flowAvatarModelFromIdentity 的过程:

export function flowAvatarModelFromIdentity(identity: string, baseColorHex: string = ''): FlowAvatarModel {
  const seed = flowAvatarSeed(identity);
  const random = new SeededRandom(seed);
  const colors = createPalette(seed, random, baseColorHex);   // 1. 调色板

  const spotCount = 9 + random.nextInt(4);                     // 2. 色斑:9~12 个
  for (let index = 0; index < spotCount; index++) {
    const angle = random.between(0, Math.PI * 2);
    const distance = random.between(0.08, 0.48);
    spots.push(new FlowAvatarSpot(
      0.5 + Math.cos(angle) * distance,   // x ∈ 中心附近
      0.5 + Math.sin(angle) * distance,   // y
      random.between(0.32, 0.72),         // 半径
      colors[(index + random.nextInt(colors.length)) % colors.length],
      random.between(0, Math.PI * 2),     // 相位
      1 + random.nextInt(2),              // harmonicX(谐波次数 1 或 2)
      1 + random.nextInt(2),              // harmonicY
      random.between(0.06, 0.15),         // amplitudeX
      random.between(0.06, 0.15)          // amplitudeY
    ));
  }
  spots.sort((a, b) => b.radius - a.radius);  // 大色斑先画(在下层)

  // 背景 = 主色降低明度
  return new FlowAvatarModel(seed, colors.slice(),
    shiftLightness(colors[0], baseColorHex.length === 0 ? -0.16 : -0.05),
    spots, random.between(0.18, 0.38), random.between(0.14, 0.34));
}

注意几个细节:

  • spotCount = 9 + nextInt(4) → 9~12 个色斑(测试锁定 >= 9 && <= 12)。

  • 色斑按半径降序 sort,保证大色斑在下层、小色斑在上层(画布是后画的盖住先画的)。

  • background 由主色 colors[0] 降低明度得到(shiftLightness),无 baseColor 时降 0.16,有 baseColor 时只降 0.05(因为 baseColor 已经人为指定了主色)。

3.2 baseColor 与几何彻底解耦

这是本篇最重要的设计决策之一。看 createPalette

function createPalette(seed, random, baseColorHex): Array<string> {
  const hasBase = baseColorHex.length > 0;
  const baseHsl = hasBase ? rgbToHsl(parseColorToRgb(baseColorHex)) : null;
  const baseHue = baseHsl !== null ? baseHsl.h : ((seed * GOLDEN_ANGLE) % 360);
  const relationship = HUE_RELATIONSHIPS[random.nextInt(5)];  // 从 5 套色相关系里选
  // ... 依据 baseHue + relationship[i] + 小抖动生成 5 色
}

关键:baseHue 只在「有没有 baseColor」时二选一——有 baseColor 就用它的色相,没有就用 seed * GOLDEN_ANGLE % 360(黄金角,让不同 seed 的色相尽量分散)。但无论选哪个,随机数的消耗顺序完全一致,所以:

同一个 seed,换 baseColor 只改「配色」,不改变「spot 位置/半径/相位」。

测试 baseColorRecolorsWithoutChangingGeometry 完整锁定了这个不变量:

// 换 baseColor 后,spot 的 x/y/radius/phase 全部 == 原模型的
for (let i = 0; i < plain.spots.length; i++) {
  expect(blue.spots[i].x).assertEqual(plain.spots[i].x);
  expect(blue.spots[i].y).assertEqual(plain.spots[i].y);
  expect(blue.spots[i].radius).assertEqual(plain.spots[i].radius);
  // ...
}
// 但颜色确实变了
expect(blue.colors[0] === plain.colors[0]).assertFalse();
expect(red.colors[0] === blue.colors[0]).assertFalse();

这就是「Identity ≠ Theme」的落地:seed 定形,baseColor 定色,业务主题只注入 baseColor

3.3 HSL 关系表 + 黄金角 + 明度抬升

createPalette 里有两个值得讲的点:

  1. HUE_RELATIONSHIPS 五套色相关系[0,28,-32,58,-62](邻近色)、[0,120,240,38,202](三原色)等,让同一种子的五色既有变化又不刺眼。

  2. liftLightness 抬升明度:品牌色常常偏暗(如深蓝 #1A237E),直接做头像会太暗。liftLightness 把明度「抬」到 0.54 以上:

export function liftLightness(sourceLightness: number): number {
  const target = 0.64;
  const weight = clamp(1.0 - sourceLightness, 0.45, 0.85);
  const lifted = sourceLightness * (1 - weight) + target * weight;
  return lifted < 0.54 ? 0.54 : lifted;   // 保底 0.54
}

测试 liftLightnessKeepsDarkPrimariesLuminous 锁定了「即使深蓝也 ≥ 0.54 明度」。


四、会话状态是「参数化 motion language」,不是换图

六种会话状态(idle / listening / thinking / speaking / success / error)不是六张图,而是一组运动参数的组合

class FlowAvatarMotionStyle {
  motion: number;         // 整体运动幅度
  rotation: number;       // 旋转速度
  contraction: number;    // 向心收缩
  orbit: number;          // 轨道运动
  pulse: number;          // 脉冲
  pulseScale: number;     // 脉冲缩放
  glow: number;           // 光晕
  highlightTravel: number; // 高光移动
  highlightSpin: number;   // 高光旋转
  tint: string;            // 染色
  tintAmount: number;      // 染色强度
  washAmount: number;      // 洗色强度
}

每种状态一套参数,例如:

状态

motion

rotation

tint

tintAmount

速度倍率

idle

1

0.18

透明

0

1

thinking

1.85

2.6

#B48CFF(紫)

0.22

2.4

speaking

1.35

0.55

#5FD0FF(青)

0.18+amp

1.55

error

0.35

0.12

#FF3B55(红)

0.48

0.55

速度倍率单独由 flowAvatarStateSpeed 给出(thinking 最快 2.4×,error 最慢 0.55×),乘在基准 8 秒一圈上:

export function flowAvatarStateSpeed(state: FlowAvatarState): number {
  if (state === Listening) return 0.7;
  if (state === Thinking) return 2.4;
  if (state === Speaking) return 1.55;
  if (state === Success) return 1.15;
  if (state === Error) return 0.55;
  return 1;  // idle
}

speaking 最特别,它额外消费 audioAmplitude(0~1 的归一化音量),让脉冲和染色强度跟着音量走:

const amp = clamp(audioAmplitude, 0, 1);
const syntheticSpeak = 0.45 + amp * 0.55;  // 0.45 是「合成的说话基准」,无音频也动
// pulse = 0.09 + syntheticSpeak * 0.12
// tintAmount = 0.18 + amp * 0.12

即使没有真实音频,也保留 0.45 的「假说话」基准,头像不会僵住。


五、重点:循环回绕接缝跳变的诊断与修复

这是 E016 最有分量的工程教训,也是实验文档单独开了一节(§5 Resolved Issue)。

5.1 现象

动画「跑完一圈」时明显跳动,像突然切回第一帧。

5.2 根因

旧时钟用的是「取模循环」:

// 旧实现(有 bug)
const t = (Date.now() % durationMs) / durationMs;  // 0 → 1
const theta = t * 2π;

看似没问题,但 motion 里含非整数频率

rotation: 0.18                // 不是 1 的整数倍
Math.sin(theta * 2.4)         // 2.4 不是整数
Math.sin(theta * 1.5)         // 1.5 不是整数
Math.sin(theta * spot.harmonicX + phase)  // harmonicX 可能是 1 或 2

theta 回绕到 0 时,sin(2π × 2.4)sin(0 × 2.4)(因为 2.4 不是整数),于是回绕瞬间位置/半径不连续——这就是接缝。

5.3 修复:连续相位,不做视觉 loop 边界

// 新实现:连续累加,不强制 0→1 闭环
const ω = (2π / 8) * speed * stateSpeed;   // idle 基准 8s 一圈
phaseRadians += Math.min(dt, 0.05) * ω;    // 连续累加
paint(..., phaseRadians);

配套几条纪律:

  • 暂停 / animated=false:冻结 phaseRadians

  • 改 speed / state:延续相位,避免二次跳变(onMotionChanged 里只 stop/start,不重置 phase)。

  • 可选大周期取模仅用于浮点稳定PHASE_WRAP = 2π×64),不是视觉 loop 边界。

组件里的实现:

private advancePhase(nowMs: number): void {
  if (this.lastFrameMs > 0) {
    const dt = Math.min((nowMs - this.lastFrameMs) / 1000, MAX_FRAME_DELTA_SECONDS);  // 0.05 封顶
    if (dt > 0) {
      this.phaseRadians += dt * this.angularSpeedRadPerSec();
      if (this.phaseRadians > PHASE_WRAP || this.phaseRadians < -PHASE_WRAP) {
        this.phaseRadians = this.phaseRadians % (Math.PI * 2);  // 大周期才取模,防浮点漂移
      }
    }
  }
  this.lastFrameMs = nowMs;
}

注意两个细节:

  1. MAX_FRAME_DELTA_SECONDS = 0.05:后台恢复时 dt 可能巨大(几十秒),直接乘会「瞬移」一大圈。clamp 到 0.05 防止后台归来时相位突跳。

  2. PHASE_WRAP = 2π×64:连续累加久了浮点精度会下降(sin/cos 在超大参数下精度变差),所以到 128π 才取一次模——这和「视觉 loop」无关,纯粹是数值稳定。

5.4 提炼的通用规则

实验文档把这条教训升华成一条可迁移规则

有机 / 多频率叠加的 Canvas 动效:优先连续相位
仅当所有角频率均为整数倍时,才可安全使用 t % 1 无缝循环。

「连续相位」并非 bug 修复的权宜之计,而是对上游 loop 模型的有意改进(文档原话:「连续相位是对上游 loop 模型的有意改进,不是疏漏」)。


六、绘制:多层 radial 叠绘(soft blob)

paintFlowAvatarFrame 一帧的绘制顺序是六层,全部用 createRadialGradient + fillRect 叠出来:

1. background  纯色底色
2. wash        状态 tint 的整屏洗色(如 error 的红色雾)
3. spots       9~12 个 soft blob,每个一个 radial gradient
4. highlight   高光(白色 radial,中心随 theta 移动)
5. vignette    边缘暗角
6. rim         最外圈加深(模拟圆润边缘)

关键点:

  • 每个 soft blob 是一个 radial gradient,从中心 alpha 0.98 衰减到边缘 alpha 0,四段 color stop 模拟柔和晕开。

  • 色斑的位置随相位移动(多频率正弦驱动),所以「头像在流动」:

const phase = spot.phase + theta * style.rotation;
let x = spot.x + Math.sin(theta * spot.harmonicX + phase) * spot.amplitudeX * intensity * style.motion;
let y = spot.y + Math.cos(theta * spot.harmonicY + phase) * spot.amplitudeY * intensity * style.motion;
  • 每个 spot 有独立的 harmonicX/harmonicY(1 或 2)和 amplitude(0.06~0.15),所以不同色斑的流动频率、幅度都不同,形成有机的「液体流动」感。

tintAmount > 0 时,色斑颜色先用 lerpColor 向状态 tint 色插值(error 时色斑逐渐偏红),再画。这是「状态染色」的实现。

裁剪用容器 borderRadius + clip(true)(不是 Canvas 里 clip),形状三选一:

private clipRadius(): number {
  if (this.shape === Circle) return this.avatarSize / 2;
  if (this.shape === RoundedSquare) return this.avatarSize * 0.24;
  return 0;  // Square
}

七、生命周期与工程要点

7.1 用 setInterval(16) 而非 DisplaySync

FlowAvatar 是 E016(早期),用的是 setInterval(16) 而非后面组件(E022/E023/E024)的 DisplaySync:

private start(): void {
  if (!this.shouldAnimate() || this.timer >= 0) return;
  this.lastFrameMs = Date.now();
  this.timer = setInterval((): void => {
    this.advancePhase(Date.now());
    this.paint();
  }, 16);
}
private stop(): void {
  if (this.timer >= 0) { clearInterval(this.timer); this.timer = -1; }
  this.lastFrameMs = 0;
}

实验文档的 Known Limits 里明确写了「后续可选:displaySync 替代 interval」——这是一个诚实的「当时的技术选择」,也正好体现了项目里技术演进的脉络(E016 用 interval,E022 才首次在 Lab 落地 DisplaySync)。

7.2 列表/宫格限制动画实例

Lab 里只有选中的 identity 开动画,其余 animated=false。多实例 60fps Canvas 未做仪器化基准(Need Verification)。

7.3 保留属性名(又出现了)

size / shadow 是 ArkUI 保留 attribute,所以 API 用 avatarSize / showShadow。这已经是本系列第四篇踩同一个坑了(GrokBot 的 botSize、ThinkingOrbs 的 orbSize、BorderBeam 的 beamRadius),值得单开一篇总结。


八、工程要点清单

  • 复制 flowavatar/ 整目录(5 个文件),保持分层。

  • Core first:seed / model / motion 纯函数 + 单测,再接 Canvas。

  • 身份用 seed(邮箱/用户名),主题用 baseColor,二者别混。

  • 连续相位,不要照搬 0→1 AnimationController

  • 列表/网格默认静止,仅焦点/会话头像 animated=true

  • aboutToDisappear / playing=false 必须停 timer。

  • 命名避开 size / shadow / direction 等保留 attribute。

  • speaking 只消费归一化 audioAmplitude,不集成 TTS。


九、总结:确定性身份 + 连续相位的范本

FlowAvatar 是「确定性生成式头像」在鸿蒙上的范本,三个核心思想:

确定性身份 = 字符串 seed → uint32 → 可复现 PRNG。
Identity ≠ Theme = seed 定形、baseColor 定色、二者解耦。
多频率动效 = 连续相位,不要用 t % 1 硬循环。

它比 GrokBot / ThinkingOrbs 更轻、更业务化:几行代码就能给每个用户一个「稳定、流动、有辨识度」的默认头像,跨设备一致、可复现、可测试。如果你的鸿蒙 App 需要默认头像或会话头像,直接拷贝 flowavatar/ 目录,传 seed 是你的用户标识,baseColor 是你的品牌色,就完成了。

Logo

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

更多推荐