HarmonyOS ArkTS 状态机与动画编排:从塔罗牌阵页面看条件渲染与数组状态管理

引子:三张牌的交互比想象中复杂

星空运势应用里,塔罗牌阵页面看着最简单——抽三张牌,翻开看结果。但做起来发现坑不少:牌堆怎么叠出立体感、翻牌动画怎么控制、三张牌的状态怎么独立管理、洗牌算法怎么保证每天结果固定。每个点都不难,但串在一起就容易乱。

这个页面本质上是个小型状态机:未抽牌 → 已抽牌 → 翻开第 N 张 → 全部翻开。状态之间的转换逻辑写清楚了,UI 就自然出来了。
完整效果

页面结构:三个阶段的 UI 切换

塔罗牌阵页面的 UI 分三个阶段,由dealttarotOpen[]两个状态控制:

阶段1:未抽牌 ┌─────────────────────────┐ │ 塔罗牌阵 │ │ 默念问题·点击抽牌 │ │ │ │ ┌─────────────────┐ │ │ │ ✦ 塔罗(牌堆) │ │ │ └─────────────────┘ │ │ 🃏 抽取三张牌 │ │ 心中默念你的问题 │ └─────────────────────────┘ 阶段2:已抽牌,未翻开 ┌─────────────────────────┐ │ 过去 现在 未来 │ │ ┌──┐ ┌──┐ ┌──┐ │ │ │✦ │ │✦ │ │✦ │ │ │ │翻│ │翻│ │翻│ │ │ └──┘ └──┘ └──┘ │ │ 🔄 重新洗牌 │ └─────────────────────────┘ 阶段3:翻开解读 ┌─────────────────────────┐ │ 过去 现在 未来 │ │ ┌──┐ ┌──┐ ┌──┐ │ │ │🃏│ │🪄│ │📖│ │ │ │愚│ │魔│ │女│ │ │ └──┘ └──┘ └──┘ │ │ ┌─────────────────┐ │ │ │ 过去·愚者 │ │ │ │ 新开始·冒险 │ │ │ │ 新的旅程即将开启… │ │ │ └─────────────────┘ │ │ 🔄 重新洗牌 │ └─────────────────────────┘

三个阶段的切换逻辑:

if(!this.dealt){this.StackedDeck();// 阶段1:牌堆}else{this.DealtCards();// 阶段2/3:牌面this.Readings();// 阶段3:解读(翻开才显示)}

这种"阶段式"的 UI 切换在游戏类应用中很常见——主界面、游戏中、结算画面。关键是把阶段判断集中在一个地方,不要散落到各处。

状态设计:三个 @State 的协作

这个页面用了三个状态来控制 UI:

@StatetarotIdx:number[]=[0,1,2];// 三张牌的索引@StatetarotOpen:boolean[]=[false,false,false];// 是否翻开@Statedealt:boolean=false;// 是否已抽牌

为什么用数组而不是三个独立变量?

如果用三个独立变量:

@Statecard1Idx:number=0;@Statecard2Idx:number=1;@Statecard3Idx:number=2;@Statecard1Open:boolean=false;@Statecard2Open:boolean=false;@Statecard3Open:boolean=false;

代码会很冗长,而且ForEach无法复用。用数组可以用循环处理三张牌,代码更简洁。

数组状态的更新陷阱

ArkTS 的@State数组有个重要特性:直接修改数组元素不会触发重新渲染

// 错误:不会触发重新渲染this.tarotOpen[0]=true;// 正确:创建新数组consto: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(newPos2D(0,0))Column().width(100).height(148).borderRadius(12).backgroundColor('#181050').border({width:1,color:'#4B3F8F',style:BorderStyle.Solid}).position(newPos2D(3,2))Column().width(100).height(148).borderRadius(12).backgroundColor('#150D48').border({width:1,color:'#3B2F78',style:BorderStyle.Solid}).position(newPos2D(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(newPos2D(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{constdk:number[]=[];for(leti:number=0;i<TAROT.length;i++){dk.push(i);}lets:number=Date.now()%10007;for(leti:number=dk.length-1;i>0;i--){s=(s*1103515245+12345)%2147483648;constj:number=s%(i+1);constt: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;}// 已翻开则忽略consto: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"——把dealttarotOpen[]两个状态管好,UI 就自然出来了。写代码的时候别急着写 UI,先把状态流转图画清楚,事半功倍。