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 的协作这个页面用了三个状态来控制 UIStatetarotIdx:number[][0,1,2];// 三张牌的索引StatetarotOpen:boolean[][false,false,false];// 是否翻开Statedealt:booleanfalse;// 是否已抽牌为什么用数组而不是三个独立变量如果用三个独立变量Statecard1Idx:number0;Statecard2Idx:number1;Statecard3Idx:number2;Statecard1Open:booleanfalse;Statecard2Open:booleanfalse;Statecard3Open:booleanfalse;代码会很冗长而且ForEach无法复用。用数组可以用循环处理三张牌代码更简洁。数组状态的更新陷阱ArkTS 的State数组有个重要特性直接修改数组元素不会触发重新渲染。// 错误不会触发重新渲染this.tarotOpen[0]true;// 正确创建新数组consto:boolean[][...this.tarotOpen];o[0]true;this.tarotOpeno;这是因为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:number0;iTAROT.length;i){dk.push(i);}lets:numberDate.now()%10007;for(leti:numberdk.length-1;i0;i--){s(s*110351524512345)%2147483648;constj:numbers%(i1);constt:numberdk[i];dk[i]dk[j];dk[j]t;}this.tarotIdx[dk[0],dk[1],dk[2]];this.tarotOpen[false,false,false];this.dealtfalse;}算法原理Fisher-Yates 的核心思想是从后往前遍历每次随机选一个位置和当前位置交换初始[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] i20: 随机选 j5交换 → [0, 1, 2, 3, 4, 20, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 5] i19: 随机选 j12交换 → [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*110351524512345)%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.tarotOpeno;}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)idxi?true:v);坑 2Stack 的高度计算Stack的高度由最高的子组件决定。牌堆的四层牌高度都是 148但Stack容器设置了 170 的高度。多出来的空间是为了让最上层的牌完整显示不会被裁切。坑 3ForEach 的性能ForEach会在数据变化时重新渲染所有项。如果牌很多比如 78 张大阿尔卡纳性能可能有问题。但三张牌完全没压力。坑 4动画的叠加翻牌时牌面从虚线边框变成实线金色边框同时背景从纯色变成渐变。两个属性变化的动画叠加在一起视觉效果可能不协调。解决方案是用相同的duration350ms让动画同步避免一个快一个慢的违和感。代码改进建议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先把状态流转图画清楚事半功倍。