HarmonyOS ArkTS 状态机与动画编排:从塔罗牌阵页面看条件渲染与数组状态管理
引子:三张牌的交互比想象中复杂
星空运势应用里,塔罗牌阵页面看着最简单——抽三张牌,翻开看结果。但做起来发现坑不少:牌堆怎么叠出立体感、翻牌动画怎么控制、三张牌的状态怎么独立管理、洗牌算法怎么保证每天结果固定。每个点都不难,但串在一起就容易乱。
这个页面本质上是个小型状态机:未抽牌 → 已抽牌 → 翻开第 N 张 → 全部翻开。状态之间的转换逻辑写清楚了,UI 就自然出来了。
完整效果
页面结构:三个阶段的 UI 切换
塔罗牌阵页面的 UI 分三个阶段,由 dealt 和 tarotOpen[] 两个状态控制:
阶段1:未抽牌
┌─────────────────────────┐
│ 塔罗牌阵 │
│ 默念问题·点击抽牌 │
│ │
│ ┌─────────────────┐ │
│ │ ✦ 塔罗(牌堆) │ │
│ └─────────────────┘ │
│ 🃏 抽取三张牌 │
│ 心中默念你的问题 │
└─────────────────────────┘
阶段2:已抽牌,未翻开
┌─────────────────────────┐
│ 过去 现在 未来 │
│ ┌──┐ ┌──┐ ┌──┐ │
│ │✦ │ │✦ │ │✦ │ │
│ │翻│ │翻│ │翻│ │
│ └──┘ └──┘ └──┘ │
│ 🔄 重新洗牌 │
└─────────────────────────┘
阶段3:翻开解读
┌─────────────────────────┐
│ 过去 现在 未来 │
│ ┌──┐ ┌──┐ ┌──┐ │
│ │🃏│ │🪄│ │📖│ │
│ │愚│ │魔│ │女│ │
│ └──┘ └──┘ └──┘ │
│ ┌─────────────────┐ │
│ │ 过去·愚者 │ │
│ │ 新开始·冒险 │ │
│ │ 新的旅程即将开启… │ │
│ └─────────────────┘ │
│ 🔄 重新洗牌 │
└─────────────────────────┘
三个阶段的切换逻辑:
if (!this.dealt) {
this.StackedDeck(); // 阶段1:牌堆
} else {
this.DealtCards(); // 阶段2/3:牌面
this.Readings(); // 阶段3:解读(翻开才显示)
}

这种"阶段式"的 UI 切换在游戏类应用中很常见——主界面、游戏中、结算画面。关键是把阶段判断集中在一个地方,不要散落到各处。
状态设计:三个 @State 的协作
这个页面用了三个状态来控制 UI:
@State tarotIdx: number[] = [0, 1, 2]; // 三张牌的索引
@State tarotOpen: boolean[] = [false, false, false]; // 是否翻开
@State dealt: boolean = false; // 是否已抽牌
为什么用数组而不是三个独立变量?
如果用三个独立变量:
@State card1Idx: number = 0;
@State card2Idx: number = 1;
@State card3Idx: number = 2;
@State card1Open: boolean = false;
@State card2Open: boolean = false;
@State card3Open: boolean = false;
代码会很冗长,而且 ForEach 无法复用。用数组可以用循环处理三张牌,代码更简洁。
数组状态的更新陷阱
ArkTS 的 @State 数组有个重要特性:直接修改数组元素不会触发重新渲染。
// 错误:不会触发重新渲染
this.tarotOpen[0] = true;
// 正确:创建新数组
const o: boolean[] = [...this.tarotOpen];
o[0] = true;
this.tarotOpen = o;
这是因为 @State 检测的是引用变化,不是内容变化。this.tarotOpen[0] = true 没有改变数组的引用,所以框架检测不到变化。
展开运算符 [...this.tarotOpen] 创建了一个新数组,引用变了,框架才会触发重新渲染。
牌堆视觉:Stack 的层叠技巧
未抽牌时,三张牌叠在一起,用 Stack 实现:
Stack() {
Column().width(100).height(148).borderRadius(12).backgroundColor('#1C1258')
.border({ width: 1, color: '#5B4FAF', style: BorderStyle.Solid })
.position(new Pos2D(0, 0))
Column().width(100).height(148).borderRadius(12).backgroundColor('#181050')
.border({ width: 1, color: '#4B3F8F', style: BorderStyle.Solid })
.position(new Pos2D(3, 2))
Column().width(100).height(148).borderRadius(12).backgroundColor('#150D48')
.border({ width: 1, color: '#3B2F78', style: BorderStyle.Solid })
.position(new Pos2D(6, 4))
Column() {
Text('✦').fontSize(36).fontColor('#8B7BC0')
Text('塔 罗').fontSize(12).fontColor('#5B4FAF').margin({ top: 6 })
}
.width(100).height(148).justifyContent(FlexAlign.Center).borderRadius(12)
.linearGradient({ angle: 135, colors: [['#1C1258',0],['#2D1F70',1]] })
.border({ width: 1.5, color: '#7B6FBF', style: BorderStyle.Solid })
.position(new Pos2D(9, 6))
}
.width(120).height(170)

层叠的视觉原理
四张"牌"从后往前叠放,每张偏移一点:
| 层级 | 坐标 | 背景色 | 作用 |
|---|---|---|---|
| 最后 | (0, 0) | #1C1258 | 底层 |
| 倒数第二 | (3, 2) | #181050 | 中间层 |
| 倒数第三 | (6, 4) | #150D48 | 中间层 |
| 最前 | (9, 6) | 渐变 | 牌面(带符号) |
这种"偏移交错"制造了厚度感,比四张牌完全重叠更真实。颜色从浅到深再到渐变,也增强了立体感。
linearGradient 的用法
最上层的牌面用了线性渐变:
.linearGradient({ angle: 135, colors: [['#1C1258',0],['#2D1F70',1]] })
angle: 135:渐变方向,从左上到右下colors:数组格式[[颜色, 位置], ...],0 是起点,1 是终点
渐变比纯色更有质感,适合做卡牌、按钮等需要"高级感"的元素。
洗牌算法:Fisher-Yates Shuffle
抽牌前需要洗牌,用的是经典的 Fisher-Yates 算法:
shuffle(): void {
const dk: number[] = [];
for (let i: number = 0; i < TAROT.length; i++) { dk.push(i); }
let s: number = Date.now() % 10007;
for (let i: number = dk.length - 1; i > 0; i--) {
s = (s * 1103515245 + 12345) % 2147483648;
const j: number = s % (i + 1);
const t: number = dk[i]; dk[i] = dk[j]; dk[j] = t;
}
this.tarotIdx = [dk[0], dk[1], dk[2]];
this.tarotOpen = [false, false, false];
this.dealt = false;
}

算法原理
Fisher-Yates 的核心思想是从后往前遍历,每次随机选一个位置和当前位置交换:
初始:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
i=20: 随机选 j=5,交换 → [0, 1, 2, 3, 4, 20, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 5]
i=19: 随机选 j=12,交换 → [0, 1, 2, 3, 4, 20, 6, 7, 8, 9, 10, 11, 19, 13, 14, 15, 16, 17, 18, 12, 5]
...
时间复杂度 O(n),空间复杂度 O(1),是洗牌算法的最优解。
为什么不用 Math.random()?
代码用了一个线性同余生成器(LCG)来代替 Math.random():
s = (s * 1103515245 + 12345) % 2147483648;
原因是:同一天洗牌结果固定。
Date.now() % 10007 作为种子,同一天运行多次结果相同。这样用户每天看到的三张牌是固定的,但不同天不一样。
如果用 Math.random(),每次刷新都会得到不同的牌,失去了"今日运势"的意义。
只取前三张
this.tarotIdx = [dk[0], dk[1], dk[2]];
洗完牌后只取前三个索引,对应"过去、现在、未来"三张牌。这种"三牌阵"是塔罗牌最经典的牌阵之一。
发牌与翻牌:条件渲染的实战
发牌后的 UI 用条件渲染切换牌面和牌背:
if (this.tarotOpen[i]) {
// 已翻开:显示牌面
Column() {
Text(TAROT[this.tarotIdx[i]].emoji).fontSize(38)
Text(TAROT[this.tarotIdx[i]].name)
.fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFD700').margin({ top: 4 })
}
.width(94).height(138).justifyContent(FlexAlign.Center).borderRadius(12)
.linearGradient({ angle: 180, colors: [['#1C1258',0],['#2D1F70',1]] })
.border({ width: 1, color: '#FFD700', style: BorderStyle.Solid })
.animation({ duration: 350 })
} else {
// 未翻开:显示牌背
Column() {
Text('✦').fontSize(32).fontColor('#8B7BC0')
Text('点击翻开').fontSize(9).fontColor('#5B4FAF').margin({ top: 6 })
}
.width(94).height(138).justifyContent(FlexAlign.Center).borderRadius(12)
.backgroundColor('#181050')
.border({ width: 1, color: '#5B4FAF', style: BorderStyle.Dashed })
.onClick(() => { this.doFlip(i); })
.animation({ duration: 350 })
}
牌面 vs 牌背的视觉差异
| 属性 | 牌面 | 牌背 |
|---|---|---|
| 背景 | 线性渐变 | 纯色 #181050 |
| 边框 | 实线金色 | 虚线紫色 |
| 内容 | emoji + 名称 | ✦ + “点击翻开” |
| 交互 | 无 | onClick 翻牌 |
这种对比让用户一眼就能区分已翻开和未翻开的牌。
翻牌逻辑
doFlip(i: number): void {
if (this.tarotOpen[i]) { return; } // 已翻开则忽略
const o: boolean[] = [...this.tarotOpen];
o[i] = true;
this.tarotOpen = o;
}
if (this.tarotOpen[i]) { return; } 这行很重要——防止重复点击已翻开的牌。如果不加这个判断,用户可以无限点击,虽然不会出错,但体验不好。
解读区域:数据映射的展示
三张牌全部翻开后,下方显示解读:
Readings() {
Column() {
ForEach([0, 1, 2], (i: number) => {
if (this.tarotOpen[i]) {
Column() {
Row() {
Text(TAROT[this.tarotIdx[i]].emoji).fontSize(26)
Column() {
Text(this.posLabel(i) + ' · ' + TAROT[this.tarotIdx[i]].name)
.fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FFD700')
Text(TAROT[this.tarotIdx[i]].key)
.fontSize(10).fontColor('#7B6FBF').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
}
.width('100%')
Text(TAROT[this.tarotIdx[i]].text)
.fontSize(12).fontColor('#BBAAE0').lineHeight(20).margin({ top: 10 })
}
.width('100%').padding(14)
.backgroundColor('#0E0B25').borderRadius(12)
.border({ width: 0.5, color: '#2D1F58', style: BorderStyle.Solid })
.margin({ bottom: 10 })
.animation({ duration: 400 })
}
})
}
.width('100%').padding({ left: 16, right: 16, top: 14 })
}
数据映射关系
每张牌的显示内容从 TAROT 数组中获取:
tarotIdx[i] → TAROT[索引] → { emoji, name, key, text }
posLabel(i) → "过去" / "现在" / "未来"
这种"索引 → 数据 → UI"的映射模式在列表展示中很常见。好处是 UI 代码和数据完全解耦,修改数据不会影响 UI 逻辑。
动画的编排
解读区域的每张牌都有 animation({ duration: 400 })。当用户翻开一张牌时,对应的解读卡片会在 400ms 内渐入显示。
三张牌的解读是独立的——用户可以先翻"过去",再翻"未来",每翻一张就多显示一个解读卡片。这种"渐进式揭示"比"全部翻开后一次性显示"体验更好。
踩坑记录
坑 1:数组状态的引用问题
ArkTS 的 @State 数组是引用类型。直接修改元素不会触发渲染,必须创建新数组。这是从 React 迁移过来的开发者最容易犯的错误。
// 这样不会触发渲染
this.tarotOpen[0] = true;
// 必须这样
this.tarotOpen = [true, this.tarotOpen[1], this.tarotOpen[2]];
// 或者用展开运算符
this.tarotOpen = [...this.tarotOpen].map((v, idx) => idx === i ? true : v);
坑 2:Stack 的高度计算
Stack 的高度由最高的子组件决定。牌堆的四层牌高度都是 148,但 Stack 容器设置了 170 的高度。多出来的空间是为了让最上层的牌完整显示,不会被裁切。
坑 3:ForEach 的性能
ForEach 会在数据变化时重新渲染所有项。如果牌很多(比如 78 张大阿尔卡纳),性能可能有问题。但三张牌完全没压力。
坑 4:动画的叠加
翻牌时,牌面从虚线边框变成实线金色边框,同时背景从纯色变成渐变。两个属性变化的动画叠加在一起,视觉效果可能不协调。
解决方案是用相同的 duration(350ms)让动画同步,避免"一个快一个慢"的违和感。
代码改进建议
1. 翻牌动画
当前翻牌只是简单的条件渲染切换,没有翻转效果。可以用 3D 旋转实现真正的翻牌动画:
.rotate({ angle: this.tarotOpen[i] ? 0 : 180, axis: { x: 0, y: 1, z: 0 } })
但这需要 ArkUI 的 3D 变换支持,当前版本可能有限制。
2. 音效
抽牌和翻牌可以加音效,增强沉浸感。ArkUI 支持 @ohos.multimedia.media 播放音频。
3. 牌组扩展
当前只有 21 张大阿尔卡纳。如果要支持 78 张完整牌组,需要分"大阿尔卡纳"和"小阿尔卡纳"两类,数据结构也需要扩展。
4. 保存历史
每次抽牌的结果可以保存到本地,让用户回顾。可以用 Preferences API 或关系型数据库。
总结
塔罗牌阵页面虽然功能不复杂,但涉及了 ArkUI 开发的多个核心知识点:状态机设计、数组状态管理、Stack 层叠布局、Fisher-Yates 洗牌算法、条件渲染、动画编排。在实际开发中,这些知识点会反复出现,只是复杂度不同。
适用边界:这个页面适合用作 ArkUI 状态管理和条件渲染的学习案例,涵盖了 @State 数组、Stack 布局、ForEach 渲染、animation 动画等核心知识点。但如果要上架应用商店,还需要补充翻转动画、音效、历史保存、完整牌组等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
说白了,这个页面的核心就是"状态驱动 UI"——把 dealt 和 tarotOpen[] 两个状态管好,UI 就自然出来了。写代码的时候别急着写 UI,先把状态流转图画清楚,事半功倍。
更多推荐



所有评论(0)