HarmonyOS 鸿蒙 FlowAvatar 确定性渐变头像实战 —— seed 生成身份、连续相位与接缝修复
一、前言:默认头像的「确定性」难题
先看两个真实痛点:
-
首字母头像:
王小明和王小强都是「王」,撞色撞形,毫无辨识度。 -
随机头像:每次启动都不一样,用户找不到「我的那个头像」,跨设备也不一致。
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
flowAvatarSeed 用 FNV-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);
}
两个关键细节:
-
charCodeAt(UTF-16 code unit):注释明确写了「Iterates UTF-16 code units to match Dart/JS semantics」——Dart 的字符串是 UTF-16 编码,用charCodeAt才能和 Flutter 上游得到逐字节一致的结果(否则中文、emoji 会算出不同的 seed)。 -
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 里有两个值得讲的点:
-
HUE_RELATIONSHIPS五套色相关系:[0,28,-32,58,-62](邻近色)、[0,120,240,38,202](三原色)等,让同一种子的五色既有变化又不刺眼。 -
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 |
|
0.22 |
2.4 |
|
speaking |
1.35 |
0.55 |
|
0.18+amp |
1.55 |
|
error |
0.35 |
0.12 |
|
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 从 2π 回绕到 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;
}
注意两个细节:
-
MAX_FRAME_DELTA_SECONDS = 0.05:后台恢复时dt可能巨大(几十秒),直接乘会「瞬移」一大圈。clamp 到 0.05 防止后台归来时相位突跳。 -
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 是你的品牌色,就完成了。
更多推荐





所有评论(0)