HarmonyOS今天吃什么——转盘的随机算法与动画实现
“今天吃什么"是每个选择困难症患者的终极难题。这个 App 用一个转盘来解决——点击"开始”,emoji 在屏幕上快速轮转,最后停在一个随机美食上。动画的核心不是真旋转,而是 setTimeout 驱动的定时切换——每隔 60ms 切换一个 emoji,速度逐渐变慢,模拟"转盘减速"的视觉效果。这个方案比 Canvas 动画简单得多,但效果足够好。
完整效果
一、单页四 Tab 架构
| Tab | 图标 | 功能 |
|---|---|---|
| 转盘 | 🎰 | 核心功能——随机选美食 |
| 管理 | 📋 | 增删改查美食列表 |
| 历史 | 📜 | 查看历史选择记录 |
| 设置 | ⚙️ | 主题切换、数据管理 |
整个 App 只有一个页面——Index.ets。 四个 Tab 用 if-else 条件渲染——和时光胶囊 App 的架构完全一致。这是单页面 App 的标准模式——简单、直接、无路由开销。
二、数据模型
FoodItem 接口
interface FoodItem {
id: string // 唯一标识
name: string // 美食名称
emoji: string // 表情符号
category: string // 分类 key
isFavorite: boolean // 是否收藏
createdAt: number // 创建时间
}

PickRecord 接口
interface PickRecord {
foodId: string // 选中的美食 ID
foodName: string // 美食名称(冗余,方便展示)
emoji: string // 表情(冗余)
pickedAt: number // 选择时间
}
CATEGORIES 常量
const CATEGORIES: FoodCategory[] = [
{ key: 'all', name: '全部', emoji: '🍽️' },
{ key: 'chinese', name: '中餐', emoji: '🍜' },
{ key: 'western', name: '西餐', emoji: '🥩' },
{ key: 'japanese', name: '日料', emoji: '🍣' },
{ key: 'korean', name: '韩餐', emoji: '🍲' },
{ key: 'dessert', name: '甜品', emoji: '🍰' },
{ key: 'drink', name: '饮品', emoji: '🧋' }
]

7 个分类——覆盖主要餐饮类型。 "全部"分类是特殊的——它不是真正的分类,而是"不限分类"的选项。
三、转盘动画的核心算法
spin 方法的完整实现
private spin(): void {
if (!this.db || this.foods.length === 0) return;
this.spinning = true; this.picked = null;
let c = 0; const total = 15 + Math.floor(Math.random() * 10);
const tick = (): void => {
this.spinIdx = c % this.foods.length; c++;
if (c < total) { setTimeout(tick, 60 + c * 8); }
else {
this.db!.pick(this.pickCat === 'all' ? undefined : this.pickCat)
.then((f: FoodItem | null) => {
this.picked = f; this.spinning = false; this.load();
});
}
};
tick();
}

动画参数拆解
| 参数 | 值 | 含义 |
|---|---|---|
| total | 15 + random(0~9) | 总切换次数(15~24 次) |
| 初始间隔 | 60ms | 第一次切换的速度 |
| 间隔增量 | +8ms/次 | 每次切换后间隔增加 |
| 最终间隔 | 60 + 24×8 = 252ms | 最后一次切换的速度 |
速度变化曲线
第 1 次:60ms ← 最快
第 2 次:68ms
第 3 次:76ms
...
第 15 次:180ms ← 中速
...
第 24 次:252ms ← 最慢(停止)
间隔从 60ms 逐渐增加到 252ms——模拟物理转盘的减速效果。 用户看到的是:emoji 越来越慢,最后"咔嗒"一声停在某个美食上。这比匀速切换更有"真实感"。
为什么用 setTimeout 而不是动画框架
| 方案 | 复杂度 | 控制力 | 适用场景 |
|---|---|---|---|
| setTimeout 链 | 低 | 高(精确控制每帧) | 简单定时切换 |
| Animation API | 中 | 中 | 平滑过渡动画 |
| Canvas | 高 | 最高 | 复杂图形动画 |
转盘不需要平滑过渡——只需要快速切换 emoji。 setTimeout 链是最简单的方案——每一帧手动切换,不需要动画框架的插值计算。
四、转盘的三种视觉状态
状态一:准备中(未开始)
Text('🎰').fontSize(80)
Text('点击下方按钮').fontSize(FontSize.md)
.fontColor(this.gc().textSecondary).margin({ top: Spacing.md })
大号 🎰 emoji + 引导文字。 用户一眼就知道该做什么——点按钮。
状态二:转动中
Text(this.foods[this.spinIdx]?.emoji || '🎰').fontSize(80)
Text(this.foods[this.spinIdx]?.name || '...')
.fontSize(FontSize.xxl).fontColor(this.gc().text)
.fontWeight(FontWeight.Bold).margin({ top: Spacing.md })
显示当前快速切换的 emoji + 名称。 用户看到 emoji 在变——知道"正在选"。spinIdx 每 60ms 更新一次——视觉上就是 emoji 在快速轮转。
状态三:选中结果
Text(this.picked.emoji).fontSize(80)
Text('🎉 ' + this.picked.name + ' 🎉')
.fontSize(FontSize.xxl).fontColor(this.gc().text)
.fontWeight(FontWeight.Bold).margin({ top: Spacing.md })
Text('就决定吃这个了!').fontSize(FontSize.md)
.fontColor(this.gc().textSecondary).margin({ top: Spacing.sm })
大 emoji + 🎉 包裹的名称 + 结论文字。 🎉 营造庆祝氛围——“选到了!”。
三种状态的切换逻辑
未开始 → 点击按钮 → spinning=true → 转动中
转动中 → tick 结束 → spinning=false, picked=有值 → 选中结果
选中结果 → 点击按钮 → spinning=true → 转动中(重新开始)
if-else 三选一——同一时刻只显示一种状态。 spinning 和 picked 两个布尔值控制三种状态的切换。
五、转盘的随机性保证
随机总次数
const total = 15 + Math.floor(Math.random() * 10)
每次转盘的总切换次数不同(15~24 次)——增加不确定性。 用户无法预测"还要转多久"。如果总次数固定,用户转几次就能预判结果。
实际选择的随机性
this.db!.pick(this.pickCat === 'all' ? undefined : this.pickCat)
.then((f: FoodItem | null) => { ... })
最终结果由 db.pick() 决定——不是由 spinIdx 决定。 spinIdx 只是视觉效果——快速切换的 index。实际选中是数据库层面的随机——两者独立。
为什么要分离视觉随机和结果随机
视觉随机保证"看起来在选"——结果随机保证"真的随机"。 如果用 spinIdx 的最终值作为结果,用户可能观察到规律(比如"每次都停在第 3 个")。分离后,视觉和结果完全独立——不可能被预测。
六、按钮的状态设计
Row() {
Text('🎰 开始').fontSize(FontSize.xl).fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
}
.width('80%').height(56)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.foods.length > 0 && !this.spinning
? this.gc().primary
: this.gc().border)
.borderRadius(BorderRadius.xl)
.onClick(() => this.spin())
.margin({ bottom: Spacing.xl })
按钮的两种状态
| 状态 | 条件 | 背景色 | 可点击 |
|---|---|---|---|
| 活跃 | 有美食 && 未在转 | primary(主色) | 是 |
| 禁用 | 无美食或正在转 | border(灰色) | 否 |
用颜色变化暗示可点击状态——不需要禁用 onClick。 用户看到灰色就知道"点不了"——即使点了也不会触发(spin 方法开头有 if (this.db.foods.length === 0) return 保护)。
七、分类筛选的设计
筛选标签的结构
Scroll() {
Row() {
ForEach(CATEGORIES.slice(0, 6), (c: FoodCategory) => {
Text(c.emoji + ' ' + c.name)
.fontColor(this.pickCat === c.key ? '#FFFFFF' : this.gc().text)
.backgroundColor(this.pickCat === c.key ? this.gc().primary : this.gc().surface)
.border({ width: 1, color: this.pickCat === c.key ? this.gc().primary : this.gc().border })
.onClick(() => { this.pickCat = c.key; this.load(); })
})
}
}
.scrollable(ScrollDirection.Horizontal)
CATEGORIES.slice(0, 6) 的含义
只显示前 6 个分类——不含"饮品"。 转盘默认显示中餐/西餐/日料/韩餐/甜品/饮品之外的分类?不对——slice(0, 6) 是 index 0 到 5,包含"全部/中餐/西餐/日料/韩餐/甜品",不含"饮品"。
为什么不显示所有 7 个分类? 屏幕宽度有限——7 个标签会太拥挤。前 6 个已经覆盖主要场景。
筛选与数据加载
.onClick(() => { this.pickCat = c.key; this.load(); })
点击分类后立即 load()——更新 foods 数组。 转盘只会从当前筛选的分类中选择——选"中餐"就只在中餐里随机。
load 方法的筛选逻辑
private load(): void {
if (!this.db) return;
this.foods = this.db.getFoods(
this.pickCat === 'all' ? undefined : this.pickCat
);
this.history = this.db.getHistory().slice(0, 30);
}
pickCat === 'all' 时传 undefined——数据库返回全部食物。 这是"全部"分类的特殊语义——不传分类参数等于不过滤。
八、空状态的设计
转盘 Tab 的空状态
if (this.foods.length === 0)
→ 按钮灰色
→ 点击 spin() 直接 return
→ 不显示动画
没有美食时不能转盘——按钮变灰,点击无效。 这比"点了弹出提示"更好——用户不需要尝试就知道"没东西可选"。
美食数量提示
Text('可选 ' + this.foods.length + ' 种')
.fontSize(FontSize.xs).fontColor(this.gc().textSecondary)
.margin({ bottom: Spacing.md })
告诉用户"有多少种选择"——增加转盘的期待感。 "可选 12 种"比"可选 0 种"更有吸引力。
九、@State 变量的分工
| 变量 | 类型 | 作用 | Tab |
|---|---|---|---|
| tab | string | 当前 Tab | 全局 |
| theme | ThemeType | 当前主题 | 全局 |
| foods | FoodItem[] | 当前分类的食物列表 | 转盘/管理 |
| history | PickRecord[] | 历史记录(最近30条) | 历史 |
| pickCat | string | 当前筛选分类 | 转盘 |
| picked | FoodItem | null | 选中的食物 | 转盘 |
| spinning | boolean | 是否在转 | 转盘 |
| spinIdx | number | 当前轮转的索引 | 转盘 |
| newName | string | 新食物名称 | 管理 |
| newEmoji | string | 新食物 emoji | 管理 |
| newCat | string | 新食物分类 | 管理 |
| showClear | boolean | 是否显示清空弹窗 | 历史/设置 |
12 个 @State 变量——比时光胶囊的 20 个少。 因为这个 App 只有 3 个数据模型(FoodItem/PickRecord/Category),比时光胶囊的 Capsule 简单。
十、数据刷新的时机
| 触发 | 方法 | 刷新内容 |
|---|---|---|
| 首次加载 | init() → load() | foods + history |
| 切换 Tab | onClick → load() | foods + history |
| 点击分类 | onClick → load() | foods |
| 转盘结束 | spin() → load() | foods + history |
| 新增食物 | addFood() → load() | foods |
| 删除食物 | del() → load() | foods |
| 切换收藏 | fav() → load() | foods |
| 清空历史 | clr() → load() | history |
| 恢复默认 | reset() → load() | foods |
每次数据变化后都调用 load()——保证 UI 和数据同步。 load() 是"刷新"的统一入口——不需要针对每种操作写不同的刷新逻辑。
十一、转盘的用户体验设计
随机性的仪式感
用户点击"开始"
→ emoji 快速轮转(60ms/次)
→ 逐渐减速(+8ms/次)
→ 最终停在某个美食上
→ 🎉 庆祝效果
整个过程约 1~2 秒——足够营造期待感,不会太长让用户不耐烦。 15~24 次切换 × 平均 120ms ≈ 2~3 秒。用户的心理:点击→等待→看到结果——这个"等待"就是仪式感。
为什么不是立即显示结果
立即显示"麻辣香锅"没有仪式感——用户会觉得"随便选的"。 转盘动画让用户觉得"系统在认真选"——虽然本质是随机数,但动画让随机变得"有过程"。
分类筛选增加控制感
纯随机让用户觉得"系统在选"——分类筛选让用户觉得"我在选范围,系统在范围内选"。 这种"半控制"状态比完全随机更有参与感——用户不是被动接受,而是主动设定范围。
更多推荐



所有评论(0)