HarmonyOS ArkTS 随机决策器的动画编排与 LCG 随机数
引子:随机数不靠 Math.random
这个随机决策器有个特别的地方——没有用 Math.random(),而是自己实现了一个线性同余随机数生成器(LCG)。原因很简单:Math.random() 在某些设备上的分布不够均匀,而且不可控。LCG 虽然简单,但足够用,而且可以复现结果(只要种子相同)。
除了随机数算法,这个页面的另一个亮点是动画编排——硬币翻转 10 个阶段、骰子摇晃 6 个阶段、随机数跳动 8 个阶段,每个阶段的时长和缓动都不同。今天就从代码出发,拆解 LCG 随机数和链式动画的实现。
完整效果

三个 Tab 的状态管理
enum RandTab { COIN = 0, DICE = 1, NUMBER = 2 }
@State tab: RandTab = RandTab.COIN;

为什么用 enum 而不是 number
用 RandTab.COIN 比 0 更语义化——看到代码就知道"这是硬币 Tab",而不是"这是第 0 个 Tab"。enum 编译后就是数字,不影响性能。
Tab 切换的实现
Button('🪙 硬币')
.backgroundColor(this.tab === RandTab.COIN ? '#FF6B35' : '#2A2A3E')
.fontColor(this.tab === RandTab.COIN ? '#FFF' : '#888')
.onClick(() => this.tab = RandTab.COIN)
选中的 Tab 用橙色背景(#FF6B35),未选中的用深色背景(#2A2A3E)。通过 this.tab 的值决定样式。
三个 if 而不是 switch
if (this.tab === RandTab.COIN) { ... }
if (this.tab === RandTab.DICE) { ... }
if (this.tab === RandTab.NUMBER) { ... }
用三个 if 而不是 switch,是因为每个 Tab 的内容是独立的 Column,不会互相干扰。如果用 switch,需要在每个 case 里写 break,代码更冗长。
LCG 随机数:自己造轮子
private rngSeed: number = Date.now();
private rand(): number {
this.rngSeed = (this.rngSeed * 1103515245 + 12345) & 0x7fffffff;
return this.rngSeed / 0x7fffffff;
}

什么是 LCG
线性同余生成器(Linear Congruential Generator)是最简单的伪随机数算法:
next = (current × a + c) & 0x7fffffff
其中 a = 1103515245,c = 12345,& 0x7fffffff 是取模 2^31。
为什么用 & 0x7fffffff
& 0x7fffffff 保留低 31 位,丢弃符号位。这样结果始终是 0 到 2^31-1 之间的正整数。
为什么除以 0x7fffffff
把整数归一化到 0-1 范围。rand() 的返回值是 [0, 1) 之间的浮点数,和 Math.random() 的范围一样。
为什么不用 Math.random()
- 可控性:LCG 的种子可以设置,方便测试
- 分布均匀:
Math.random()的实现在不同设备上可能不同,分布不一定均匀 - 复现性:相同的种子产生相同的序列,方便调试
种子的选择
Date.now() 用当前时间戳作为种子。每次启动应用种子不同,随机序列也不同。
硬币翻转:10 阶段链式动画
flipCoin(): void {
let target: number = this.rand() < 0.5 ? 0 : 1;
let ph: number = 0;
let next = (): void => {
if (ph === 0) { animateTo({ duration: 100, curve: Curve.Sharp }, () => { this.coinAngle += 90; }); }
else if (ph === 1) { animateTo({ duration: 100, curve: Curve.Sharp }, () => { this.coinAngle += 90; }); }
// ... 直到 ph === 9
ph++;
setTimeout(next, (ph === 8 ? 260 : ph === 9 ? 180 : 100 + ph * 10));
};
next();
}

动画链的原理
硬币翻转不是一次动画,而是 10 次连续动画。每次动画转 90 度,总共转 900 度(2.5 圈)。
- 阶段 0-7:每次转 90 度,时长从 100ms 递增到 170ms
- 阶段 7:切换正反面(
this.coinFace = target) - 阶段 8:放大到 1.25 倍
- 阶段 9:缩回 1 倍
时长递增的原因
开始快(100ms),结束慢(170ms),模拟真实硬币的物理特性——开始时动能大,旋转快;后来动能减小,旋转慢。
setTimeout 的延迟
setTimeout(next, (ph === 8 ? 260 : ph === 9 ? 180 : 100 + ph * 10));
- 阶段 8:等 260ms(让阶段 7 的动画完成)
- 阶段 9:等 180ms(让阶段 8 的动画完成)
- 其他:等
100 + ph * 10ms(递增延迟)
为什么不用 animateTo 的回调
animateTo 没有"动画完成"的回调。用 setTimeout 手动控制下一个动画的开始时间,实现链式效果。
coinAngle 的累加
this.coinAngle += 90; // 累加而不是赋值
每次加 90 度,10 次后总共加了 900 度。如果用赋值 this.coinAngle = 90,每次都是 90 度,硬币看起来没动。
骰子摇晃:6 阶段随机位移
rollDice(): void {
this.diceReady = false;
// 先计算最终结果
let finalVals: number[] = [];
for (let di: number = 0; di < this.diceCount; di++) {
let v: number = Math.floor(this.rand() * 6) + 1;
finalVals.push(v);
}
let ph: number = 0;
let totalPhases: number = 6;
let next = (): void => {
if (ph < totalPhases) {
// 摇晃阶段:随机位移 + 随机点数
let rem: number = totalPhases - ph;
let sx: number = Math.floor((this.rand() - 0.5) * rem * 6);
let sy: number = Math.floor((this.rand() - 0.5) * rem * 6);
let tmp: number[] = [];
for (let di: number = 0; di < this.diceCount; di++) {
tmp.push(Math.floor(this.rand() * 6) + 1);
}
animateTo({ duration: 60 + ph * 25, curve: Curve.Sharp }, () => {
this.diceMoveX = sx; this.diceMoveY = sy; this.diceVals = tmp;
});
ph++;
setTimeout(next, 70 + ph * 25);
} else {
// 停止阶段:回到原位 + 显示最终结果
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.diceMoveX = 0; this.diceMoveY = 0; this.diceScale = 1.2;
this.diceVals = finalVals; this.diceSum = finalSum; this.diceReady = true;
});
// 缩回动画
setTimeout(() => {
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => { this.diceScale = 1; });
}, 220);
}
};
next();
}
摇晃的策略
每次摇晃的位移幅度递减(rem * 6),模拟骰子从剧烈摇晃到逐渐停止的过程。
位移的随机性
let sx: number = Math.floor((this.rand() - 0.5) * rem * 6);
(this.rand() - 0.5) 产生 -0.5 到 0.5 之间的值,乘以 rem * 6 后变成随机位移。rem 越大(开始阶段),位移越大。
点数的随机显示
摇晃过程中显示随机点数,让用户感觉"骰子在滚动"。最终结果在停止阶段才显示。
两阶段停止
- 回到原位 + 放大 1.2 倍(200ms,EaseOut)
- 缩回 1 倍(300ms,EaseInOut)
先放大让用户注意到结果,再缩回恢复正常大小。
随机数跳动:8 阶段数字滚动
genRandom(): void {
let target: number = Math.floor(this.rand() * (this.randMax - this.randMin + 1)) + this.randMin;
let ph: number = 0;
let total: number = 8;
let next = (): void => {
if (ph < total) {
// 跳动阶段:在目标值附近随机波动
let mid: number = target + Math.floor((this.rand() - 0.5) * (total - ph) * 20);
animateTo({ duration: 50 + ph * 18, curve: Curve.Sharp }, () => { this.randResult = mid; });
ph++;
setTimeout(next, 55 + ph * 18);
} else {
// 停止阶段:显示最终值 + 放大缩回
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.randResult = target; this.randScale = 1.18;
});
setTimeout(() => {
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => { this.randScale = 1; });
}, 220);
}
};
next();
}
跳动的策略
每次显示的值是 target + 随机偏移。偏移幅度递减(total - ph),模拟"逐渐逼近目标"的效果。
为什么偏移乘以 20
(total - ph) * 20 控制偏移的最大幅度。开始时偏移大(±160),结束时偏移小(±20)。如果偏移太小,跳动不明显;如果偏移太大,看起来像乱码。
最终值的确定
在阶段 0 就计算好 target,跳动过程中只是在它附近波动。这样保证最终结果是随机的,跳动只是视觉效果。
Tab 切换的条件渲染
if (this.tab === RandTab.COIN) {
Column() { /* 硬币内容 */ }
.layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
}
if (this.tab === RandTab.DICE) {
Column() { /* 骰子内容 */ }
.layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
}
if (this.tab === RandTab.NUMBER) {
Column() { /* 随机数内容 */ }
.layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
}
为什么三个 Column 都加了 layoutWeight(1)
layoutWeight(1) 让 Column 占据剩余空间。三个 Column 虽然同时存在,但只有当前 Tab 的那个会显示(因为 if 条件)。不加 layoutWeight(1) 的话,Column 的高度由内容决定,可能导致布局错乱。
为什么用 justifyContent(FlexAlign.Center)
三个 Tab 的内容都居中显示,视觉上统一。如果不用居中,硬币可能在顶部,骰子在中间,随机数在底部,看起来不协调。
骰子面的 emoji 映射
diceFace(v: number): string {
let faces: string[] = ['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅'];
return faces[v];
}
为什么数组第一个是空字符串
骰子点数是 1-6,数组索引也是 0-6。索引 0 没有用,用空字符串占位,让 faces[v] 直接返回对应的 emoji。
为什么用 emoji 而不是自定义绘制
emoji 渲染快,代码简单,兼容性好。缺点是样式不可控(不同设备的 emoji 可能不同),但对于骰子这种简单图形足够了。
踩坑记录
坑 1:LCG 的周期
LCG 的周期是 2^31(约 21 亿),对于这个应用足够了。但如果需要更长的周期,可以用更复杂的算法(比如 Mersenne Twister)。
坑 2:animateTo 的嵌套
animateTo 不能嵌套——在 animateTo 的回调里再调 animateTo 会导致动画混乱。代码用 setTimeout 延迟执行,避免了嵌套。
坑 3:diceReady 的作用
diceReady 控制骰子是否可以再次摇晃。在动画过程中设为 false,防止用户连续点击导致动画冲突。
坑 4:coinAngle 的溢出
coinAngle 会无限累加(900、1800、2700…),但 CSS 的 rotate 会自动取模,不会溢出。
坑 5:randMin 和 randMax 的校验
用户可能输入非法值(比如 min > max)。代码用 parseInt(v) || 1 兜底,但没有做 min < max 的校验。如果 min > max,Math.floor(this.rand() * (max - min + 1)) 会得到负数。
代码改进建议
1. 动画参数提取
三个动画的阶段数、时长、缓动都硬编码在函数里。可以提取成配置对象:
const COIN_ANIM = {
phases: 10,
durations: [100, 100, 120, 140, 160, 180, 200, 260, 160, 200],
curves: [Curve.Sharp, ..., Curve.EaseOut, Curve.EaseOut, Curve.EaseInOut]
};
2. 动画复用
三个动画的链式结构相似(递增阶段 + setTimeout 延迟),可以抽象成通用的链式动画函数。
3. 声音反馈
硬币翻转、骰子摇晃、随机数停止时可以加音效,增强沉浸感。
4. 历史记录
保存最近的随机结果,方便用户回顾。
5. 自定义骰子面
用户可以自定义骰子的 6 个面(比如用文字代替 emoji)。
总结
随机决策器的核心是"链式动画"——用 setTimeout + animateTo 实现多阶段的连续动画。每个阶段的时长递增、缓动不同,模拟真实物理效果。LCG 随机数虽然简单,但足够用,而且比 Math.random() 更可控。
适用边界:这个部分适合用作 ArkUI 链式动画和伪随机数算法的学习案例,涵盖了 LCG 随机数、animateTo 链式调用、setTimeout 延迟、enum 状态管理、emoji 映射等核心知识点。但如果要上架应用商店,还需要补充声音反馈、历史记录、自定义骰子面、输入校验、动画参数配置化等内容。
更多推荐



所有评论(0)