第一天跟着教程把“Hello World”跑起来之后,说实话心里是有点虚的。因为这种Demo只能让你确认“环境装好了、工具能用了”,它验证不了你到底有没有真正理解ArkTS和ArkUI的思维模式。所以第二天我给自己定的目标是:不依赖教程,独立做出一个能拿得出手、有完整交互的小应用。最后选来选去,定了“生肖卡抽奖”这个题目——12张卡片盖在屏幕上,点一张翻一张,翻出来的生肖就是抽奖结果,底部一个按钮可以洗牌重新来。这个项目看起来不起眼,但把页面布局、数据绑定、状态管理、随机算法、动画触发、弹窗时序这些内容全串起来了。这篇就记录一下Day02的完整实现过程、中间的设计取舍,还有几个踩过之后才明白的坑。如果你也刚开始学鸿蒙开发,或者正打算拿一个小项目练手,这个选题和思路可以直接照搬。
1. 为什么第二天就敢碰“抽奖”这种项目
第一天学完基础之后,我其实在好几个选题里纠结过:待办清单?计算器?还是这个生肖卡抽奖?最后选它的原因可以总结成三条。
第一,页面结构不复杂但也不单薄。它是一个典型的“上下分区”:顶部标题,中间网格卡片区,底部操作按钮。这个结构能覆盖Column、Row、Grid这些最常用的容器组件,而且布局的视觉反馈非常直观——间距调没调对、对齐有没有问题,一眼就能看出来,特别适合用来建立“尺寸和布局”的直觉。
第二,交互上有明显的“状态变化”。点卡片之前和之后,UI是完全不同的两张脸:卡片从背面的问号翻到正面的生肖,抽奖结果还要以弹窗形式再提示一次。这种交互天然就需要做状态管理,正好用来理解ArkTS里@State的核心地位。
第三,随机算法让每次运行都不一样。抽奖的重点不在于写一个if-else,而在于“再来一次”的时候数据怎么重新洗牌、界面怎么归位。花几分钟把Fisher-Yates洗牌算法写进去,整个项目的完成度一下子就上来了。
1.1 功能点和学习点的对应关系
| 功能点 | 涉及的技术点 | 学习价值 |
|---|---|---|
| 12张卡片网格展示 | Grid + ForEach | 容器布局与列表渲染 |
| 卡片点击翻转 | @State + animateTo | 状态驱动UI与动画 |
| 抽奖结果弹窗 | 自定义弹窗 + 延迟调用 | 组件通信与时序控制 |
| 再来一次 | 洗牌算法 + 状态重置 | 算法落地与状态刷新 |
| 防止重复翻牌 | 状态判断 + 动画锁 | 交互边界处理 |
1.2 和一个写死的Demo相比差在哪
很多教程喜欢用一个写死的数组渲染成静态卡片列表,然后把所有逻辑堆在同一个方法里。那样其实学不到什么东西。这个项目的关键在于每一步都要自己设计数据结构和交互流程,而不是照着示例抄。对于一个第二天的新手来说,这个挑战刚好推到舒适区边缘——不会劝退,但确实要动脑子。
比如“卡片是不是可以重复点击”,这种问题教程不会告诉你,但真实用户一定会遇到。再比如“洗牌之后界面怎么更新”,这也是纯静态Demo完全没法暴露的问题。所以我做这个项目的态度是:宁可功能少一点,也要把每一个交互的“边界条件”想清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 页面骨架:从“布局直觉”到ArkUI的声明式组织
2.1 先把界面拆成三层
动手写代码之前,我并没有急着打开DevEco Studio,而是先在一张纸上画了结构草图。整个页面拆成三层:
- 标题区:一个主标题加一个副标题,说明这个页面是干什么的;
- 卡片区:12张卡,4列3行,铺满中间大部分区域;
- 操作区:底部一个按钮,负责“再抽一次”或“重新开始”。
这个拆分看起来简单,但对应到ArkUI里,每个区域使用什么容器组件是有讲究的。标题区用Column就能解决对齐问题;卡片区必须用Grid,因为它要同时满足等宽多列和自动换行两个要求;操作区也用Column,只是alignItems水平居中。
code复制build() {
Column({ space: 16 }) {
// 标题区
Column({ space: 4 }) {
Text('生肖卡抽奖')
.fontSize(28)
.fontWeight(FontWeight.Bold)
Text('点击卡片抽取你的生肖')
.fontSize(14)
.fontColor('#888888')
}
.width('100%')
.padding({ top: 24 })
// 卡片区
Grid() {
// 渲染卡片
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.width('100%')
.layoutWeight(1)
.padding(16)
// 操作区
Button('再抽一次')
.width('80%')
.height(48)
.margin({ bottom: 32 })
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
2.2 Grid参数里的几个细节
Grid最关键的参数是columnsTemplate和rowsTemplate。字符串里用空格分隔每一列或每一行的占比。上面写的'1fr 1fr 1fr 1fr'就是4列等宽,'1fr 1fr 1fr'就是3行等高。fr本质上和CSS里的flex比例是同一个思路:整体宽度100%,每一格按比例分配。这里要注意别写成四列各100%,那是新手最容易犯的错——Grid会把所有列的占比加起来按比例分配,你写4个2fr和4个1fr效果一样。
尺寸单位我统一用的vp。鸿蒙里长度单位主要是vp和px,推荐使用vp,它跟屏幕密度解耦,在不同手机上显示出来的物理尺寸差异不会太大。columnsGap(12)和rowsGap(12)设的是卡片之间的间距,这个数值很关键,我试过8vp和16vp,12vp在4列布局里视觉上最舒服,既不会显得拥挤,也不会让卡片之间的关联感断裂。
卡片圆角我设计的是外层容器borderRadius(16),里面的内容区再叠一个稍小的圆角,两层圆角叠加之后看起来会比单层圆角精致很多。这个属于纯视觉细节,不影响逻辑,但做项目的时候很影响观感。
3. 生肖数据与卡片渲染:把12张卡变成“活数据”
3.1 数据和状态必须分开
做数据模型之前,我先想清楚了一件事:这12张卡片的“数据”和“状态”是不是一回事?
我的结论是:数据是固定的,状态是变化的。12个生肖的名称、图标、主题色是固定数据;某张卡有没有翻开、目前翻到哪个生肖,属于运行时的状态。固定数据用普通数组定义就行,不要去动它;状态数据则必须放进@State里,让框架帮你监听变化。
typescript复制// 固定数据:12生肖的展示配置
const ZODIAC_LIST: string[] = [
'鼠', '牛', '虎', '兔', '龙', '蛇',
'马', '羊', '猴', '鸡', '狗', '猪'
];
// 运行时状态:每张卡片当前的结果和翻开状态
interface CardStatus {
zodiacIndex: number; // 这张卡对应的生肖下标
flipped: boolean; // 是否已经翻开
}
@State cardStatusList: CardStatus[] = [];
这样拆的好处是什么?第一,展示配置和运行数据解耦,后面如果想给每个生肖加一句运势文案,只需要在ZODIAC_LIST旁边加一个运势数组,不用动CardStatus。第二,状态数组里存的是“下标”而不是“名字”,未来如果要换图片资源,换的是展示配置,状态数据完全不用动。
3.2 初始化:洗牌赋值
App启动后需要给12张卡赋值,这个逻辑我放在一个initCards方法里:
typescript复制initCards() {
// 生成0-11的数组
let indexes: number[] = [];
for (let i = 0; i < 12; i++) {
indexes.push(i);
}
// Fisher-Yates洗牌
for (let i = indexes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indexes[i], indexes[j]] = [indexes[j], indexes[i]];
}
// 生成卡片状态
this.cardStatusList = indexes.map(idx => ({
zodiacIndex: idx,
flipped: false
}));
}
initCards在aboutToAppear里调用,页面每次加载都会重新洗牌,所以每一次进入应用看到的卡片顺序都不一样。这里我特意用了洗牌算法而不是简单随机,原因是确保12张卡正好覆盖12个生肖,不重复不遗漏。如果用Math.random每次独立生成,会有重复问题,看起来就像Bug。
Fisher-Yates的思路其实不复杂,从数组最后一个元素开始,随机挑一个前面的元素交换位置,这样每个元素出现在每个位置的概率都是相等的。对于12个元素的规模,实现成本和收益完全成正比。如果你手头有一副扑克牌,洗牌算法想要达到的效果就是“把牌彻底打乱,但一张不丢”。
3.3 ForEach渲染里的key问题
卡片区的渲染代码如下:
typescript复制Grid() {
ForEach(this.cardStatusList, (status: CardStatus, index: number) => {
GridItem() {
this.CardView(status, index)
}
}, (status: CardStatus, index: number) => `${index}_${status.zodiacIndex}`)
}
ForEach的第三个参数是key生成器,这个在鸿蒙里比在React里还重要。如果你偷懒不写key,或者直接用index当key,那么在cardStatusList整体被替换(比如“再来一次”洗牌)的时候,ArkUI复用的节点可能还是旧内容,表现出来就是刷新错乱。所以我这里用了index拼上zodiacIndex,这样即使洗牌后同一个位置的卡片变成了不同生肖,key也会变化,强制框架重建对应的GridItem。
提示:index直接作为key的问题是,洗牌后数组长度没变,每个index还在,key就没有变化,框架会认为节点没变,内容就不重新渲染。用
${index}_${status.zodiacIndex}是其中一种规避方式。
这一步虽然写在Day02的文章里,但很多做了一年项目的人都不一定注意过。养成正确key的习惯,比以后改几千行代码要轻松得多。
4. 翻牌逻辑:用状态驱动UI,而不是“改DOM”
4.1 从“手动刷新”到“改完状态就结束”
第一天写代码的时候,我还是带着Web的习惯——改完数据后总想找一个类似dom.refresh()的方法把页面刷新一下。但ArkUI的编程范式完全不是这样。它的规则是:只要你修改了@State装饰的变量,对应绑定这个变量的UI组件会自动重新渲染。
比如要翻开一张卡,我只需要:
typescript复制this.cardStatusList[index].flipped = true;
ArkUI会检测到cardStatusList这个@State变量的变化,然后自动找到所有依赖它的UI节点,重新执行渲染。你不用告诉它“请刷新这一格”,它自己就知道哪一格要变。这也是为什么状态必须放进@State里,而ZODIAC_LIST只是普通const——因为前者需要被监听,后者永远不改,不需要监听。
4.2 点击事件与防连点
卡片的点击逻辑是:
typescript复制onCardClick(index: number) {
if (this.isAnimating) return; // 动画锁
if (this.cardStatusList[index].flipped) return; // 已翻开,直接忽略
this.isAnimating = true;
// 触发翻转:状态变化被animateTo包裹,UI会自动过渡
this.cardStatusList[index].flipped = true;
// 弹出结果窗口
this.showResultDialog(this.cardStatusList[index].zodiacIndex);
// 动画和弹窗都结束之后,解除锁
setTimeout(() => {
this.isAnimating = false;
}, 600);
}
这里有几个边界情况非常值得新手注意。
第一个是重复点击。如果你不判断flipped就把卡片翻开了,第二次点击同一张卡,动画会再触发一遍,弹窗也会重复弹出,用户感觉就是这个应用疯了。别笑,我第一次跑起来就遇到了。加了flipped判断之后,已经翻开的卡就等于“死”了,不管怎么点都没反应。
第二个是翻牌速度。如果用户手速极快,在动画还没播完的时候去点另一张卡,两张卡可能会同时在动画状态里,甚至出现弹窗叠加。所以加了一个isAnimating锁,动画期间直接把所有点击吞掉。这个锁的代价是用户要等动画结束才能继续操作,换来的是交互的稳定和可控。对抽奖场景来说,这种“等待”反而制造了仪式感。
第三个是“全部翻完”的状态。我加了一个简单的判断:如果cardStatusList.every(item => item.flipped),就把按钮文案从“再抽一次”改成“重新开始”。这个小细节很便宜,但对完整性的提升非常明显。用户把12张全部揭开之后,如果按钮还是“再抽一次”,语义上就有点怪。
4.3 洗牌算法与“再抽一次”的实现
“再抽一次”的按钮只需要干两件事:重新洗牌、重置状态。代码几乎可以复用initCards:
typescript复制resetAll() {
this.initCards(); // 生成新的随机顺序
this.isAnimating = false;
this.dialogVisible = false;
}
注意这里有个小坑:initCards里重新new了一个数组赋给cardStatusList,和原来那个数组完全不同。这是刻意为之,因为@State监听的是“引用变化”,如果你在原数组上做push/splice这种原地修改,ArkUI不一定能感知到所有变化。创建一个新数组再赋值,是最稳的触发刷新的方式。这也是我在这个项目里做得最顺的一个决策:凡是需要整体刷新状态的时候,干脆整个数组换掉,不给框架留“猜”的空间。
5. 翻牌动画:从“能翻”到“翻得像回事”
5.1 旋转轴和animateTo的基础
翻牌动画里,rotate的用法是:
typescript复制.rotate({
x: 0,
y: 1,
z: 0,
angle: this.cardStatusList[index].flipped ? 180 : 0
})
x、y、z三个值定义的是旋转轴的方向。x=1绕水平轴转,效果像向前翻跟头;y=1绕垂直轴转,效果像翻牌;z=1绕屏幕中心轴转,效果像转盘。翻牌用y=1就对了。
但直接用这一句,你会发现动画是“跳”过去的:点一下,卡片瞬间变成180度,一点过渡都没有。原因是你改了状态之后,UI跳到了最终值,没有中间帧。要让中间帧出现,得用animateTo把这次修改包起来:
typescript复制animateTo({
duration: 400,
curve: Curve.EaseOut,
onFinish: () => {
// 动画结束后的回调
}
}, () => {
this.cardStatusList[index].flipped = true;
})
animateTo的工作方式很直白:第一参数是动画配置,第二参数是你想执行的“状态变化”。状态变化一旦发生,ArkUI不会立刻把UI切成终态,而是按照配置的时长和曲线,从当前状态“插值”过渡到目标状态。翻转的中间帧由框架自动生成,你不需要关心每一帧的坐标。
5.2 内容切换的时机:0到90是一个世界,90到180是另一个世界
翻转动画看起来简单,真正要处理好的是“什么时候显示正面、什么时候显示背面”。如果你在flipped为true的那一刻才显示正面,那么当卡片转到90度的时候,正面突然被替换上去,再继续转到180度,视觉上会有一点“穿帮”感。
更精致一点的做法是定义一个角度状态,根据角度动态切换内容:
typescript复制@State cardAngles: number[] = new Array(12).fill(0);
// 点击卡片时,做一个从0到180的角度动画
animateTo({ duration: 500, curve: Curve.EaseInOut }, () => {
this.cardAngles[index] = 180;
});
卡片渲染时:
typescript复制Stack() {
if (this.cardAngles[index] < 90) {
// 角度小于90度时显示背面
this.CardBack()
} else {
// 角度大于等于90度时显示正面
this.CardFront(this.cardStatusList[index])
}
}
.rotate({
x: 0,
y: 1,
z: 0,
angle: this.cardAngles[index]
})
状态不再是单纯的boolean,而是一个0到180的数值。当角度小于90度时显示背面,大于等于90度时切换为正面。视觉上就像真实的卡牌翻了一个面。Day02的项目如果用了这种做法,效果会超出很多同级别的练手项目。这比单纯地“瞬间换面”多一点工程美感,而且代码量增加得并不多。
5.3 弹窗出现的时机
弹窗如果和翻牌同时出现,会把翻牌动画完全挡住。所以弹窗应该延迟出现,等卡片完全翻开之后再弹出。我的做法是把弹窗的显示放到animateTo的onFinish回调里:
typescript复制animateTo({
duration: 500,
curve: Curve.EaseInOut,
onFinish: () => {
this.dialogVisible = true; // 动画播完才弹窗
this.isAnimating = false;
}
}, () => {
this.cardAngles[index] = 180;
});
由于onFinish表示动画已经播完,在这个回调里弹窗,用户看到的是卡片完全翻开后,结果窗口才缓缓出现。时序对了,体验就对了。如果你太早把弹窗置为可见,用户还没来得及看清楚翻出来的是什么,弹窗就把内容盖住了,那种“揭示感”就完全没了。
弹窗本身我用的是一个自定义的半透明层:
typescript复制if (this.dialogVisible) {
Column() {
Text(`恭喜你抽中了「${ZODIAC_LIST[this.currentResult]}」`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
Text('点击任意处关闭')
.fontSize(14)
.fontColor('#888888')
.margin({ top: 12 })
}
.width(260)
.padding(24)
.backgroundColor('#FFFFFF')
.borderRadius(20)
.shadow({ radius: 20, color: '#33000000' })
.onClick(() => {
this.dialogVisible = false;
})
}
这个弹窗挂在Column的最外层,盖在所有内容上面。点击弹窗本身关闭,不会触发底层卡片的点击。这样一个简单的自定义弹窗,比引入系统Dialog组件更可控,样式也更自由。
6. 模拟器和真机:今天的坑都踩在这
6.1 模拟器的环境认知
我用的模拟器是DevEco Studio自带的,但这个模拟器有平台限制。我在x86架构的笔记本上创建模拟器时,遇到了“运行设备不兼容”的提示,折腾了好一会儿才反应过来。后来查了一下发现,模拟器依赖虚拟化能力,对CPU架构有明确要求,目前主要支持arm64平台,x86设备基本没法直接用。如果你也遇到类似问题,不用怀疑自己配置错了,先看看机器架构和官方文档的兼容性说明。
如果确实没有arm64设备,也有替代方案:用真机调试,或者用云真机。真机调试需要打开开发者模式、扫码授权,DevEco Studio会自动通过hdc工具连接设备。hdc这个命令行工具的作用和Android的adb非常像,但语法不完全一样,如果你是从Android转过来的,别把adb的习惯直接带过来,很多命令名称不同。
6.2 真机调试的记录
真机调试时最容易被忽略的是签名配置。新建项目的时候,DevEco Studio默认会生成一个自动签名配置,但如果你以后改了包名或者用了自己的证书,可能就要手动走一遍签名流程。我在真机上跑这个生肖卡抽奖的时候,还好用的是默认自动签名,连接一次就通了。
连接之后,DevEco Studio可以直接把应用推到真机上运行,日志也会实时输出到Log窗口。我在模拟器上看着正常的动画,到真机上总感觉翻牌的速度快了一点,后来对比了一下,主要是因为模拟器帧率不稳,看着反而“慢半拍”。这种情况不用调代码,以真机效果为准就好。
6.3 卡片渲染的性能观察
说实话,一个只有12个卡片的Grid,谈性能优化有点小题大做。但我还是观察了一下渲染表现。在GridItem的卡片组件里,背景色和文字都直接绑定到了@State数组上。当数组整体变化时,所有GridItem都会重新渲染,这个开销在12个节点时可以忽略。
真正值得关注的优化点是ForEach的key。key生成得合理,ArkUI会精确地知道“哪个节点要重建、哪个节点可以复用”。如果key写得不好,12个节点可能全部重建,虽然这个量级感知不到,但当以后从12张卡扩展到几百条数据时,差异就显现出来了。
今天这半天学下来,最有价值的不是“生肖卡抽奖”这个应用本身,而是我终于开始习惯用“状态”去思考界面了。之前写页面总是想着“事件发生之后手动改DOM”,现在改成“事件只改数据,渲染交给框架”。这个思维一转变,很多之前觉得复杂的东西都变得顺畅了。另外一个小技巧也分享给你:如果做类似项目,可以把卡片的主题色做成动态的,翻开不同生肖时背景色跟着变化,这个反馈会让抽奖的“仪式感”更强。生肖卡抽奖做到这里,第一天的知识已经被消化得差不多了,下一步我准备挑战带网络请求的内容,到时候再记录。
