从Day01的"Hello World"走过来,今天这个生肖卡抽奖项目算是第一次真正把鸿蒙开发的各种基础概念串起来用。标题听起来像个娱乐小demo,但实际做完你会发现,里面涉及的ArkUI声明式布局、状态管理、组件复用、随机逻辑处理,甚至本地数据持久化,都是以后做任何正经鸿蒙应用都绕不开的底子。这篇文章就把我Day02踩过的坑和最终实现的完整思路从头到尾捋一遍,新手可以直接照着抄作业。
这个项目适合什么基础的人?说实话,不需要你会多少ArkTS语法,只要跟着Day01建过工程、跑起来过模拟器就行。生肖卡抽奖的核心玩法很简单:页面上展示12张生肖卡片,点击"抽奖"按钮后随机亮出一张,并记录抽奖历史。但就是这么个小东西,要把界面做得不生硬、逻辑不漏洞,其实很考验对鸿蒙开发范式的理解。适合正在从"看教程"过渡到"写小项目"阶段的同学拿来练手。
1. 项目整体设计与需求拆解
1.1 生肖卡抽奖的功能范围定义
拿到这个标题,不要上来就写代码。我的习惯是先把需求列清楚,哪怕是一个学习项目,也要有边界感。生肖卡抽奖这个项目,我给自己定义了四个核心功能模块:
- 抽奖主界面:展示12张生肖卡片,当前抽中的卡片需要高亮或放大显示。
- 抽奖交互逻辑:点击按钮,随机从12个生肖中抽取一个,并给出结果反馈。
- 结果可视化:抽中的卡片要有明显的状态变化,最好带一点动画,让用户感知到"中了"。
- 历史记录:记录每次抽奖的结果,刷新页面后依然能保留。
这四项拆出来之后,你会发现它正好对应了鸿蒙开发里的几个核心知识点:ArkUI的布局与组件化、状态管理(@State/@Prop)、事件绑定与方法调用、数据持久化(Preferences或PersistentStorage)。一个项目练完,这四个知识点吃透了,后面的路就顺了。
1.2 技术选型与整体架构考虑
鸿蒙开发目前主流的应用开发方式是基于ArkTS的声明式开发范式。这个项目我选择的就是Stage模型 + ArkTS + ArkUI,这是DevEco Studio新建工程的默认方案,也是目前鸿蒙应用开发最主流的技术栈。为什么不用Java或者前端那种类Web方式?因为从学习角度讲,声明式UI是鸿蒙的未来方向,早点适应这种"数据驱动UI"的思维方式,比抱着旧范式不放要划算得多。
架构上我做了最简单的分层:UI层(页面组件)和逻辑层(抽奖算法和数据存储)分开。虽然项目小,但养成这个习惯以后维护成本会低很多。UI层只管渲染状态,逻辑层只负责生成随机结果和读写数据,中间通过状态变量进行通信。这种模式在鸿蒙开发里叫"单向数据流",是声明式UI的核心思想。
1.3 为什么选择生肖作为业务载体
这里多说一句。很多新手学鸿蒙的时候喜欢拿"待办事项""计算器"练手,不是说不行,而是这两个项目在UI层面太单调了,练不到布局和交互。生肖卡抽奖的优势在于:
- 数据模型简单:12个字符串、12张图片,不需要后端。
- UI元素丰富:卡片、网格、按钮、弹窗、动画,样样都有。
- 交互逻辑明确:随机抽卡,天然适合练习事件处理。
- 可扩展性强:后期想加"抽卡动画""概率控制""分享结果",都有地方加。
所以这个项目的容量恰好能让你在一天内学完并动手写完,不至于像大项目一样写着写着就放弃了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 ArkUI页面布局的编排思路
鸿蒙的ArkUI布局,核心就是"容器套容器"。最常用的是Column(纵向排列)和Row(横向排列),这俩跟Flutter的Column/Row几乎一模一样,用熟了之后非常顺手。生肖卡抽奖的主界面我采用了这样的层级结构:
- 最外层是一个Column,垂直方向分三段。
- 顶部是标题区和当前抽中的生肖展示。
- 中间是一个Grid网格,用来排列12张生肖卡片。
- 底部是抽奖按钮和历史记录入口。
这段布局如果用传统命令式UI写,你得手动计算每个控件的frame,非常痛苦。但在ArkUI里,你只需要声明"这有一个Grid,每行4列",剩下的交给系统去排。代码如下:
typescript复制@Entry
@Component
struct Index {
@State zodiacList: string[] = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
build() {
Column() {
// 标题区
Text('生肖卡抽奖')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 10 })
// 抽中展示区
Text(this.currentResult.length > 0 ? '恭喜抽中:' + this.currentResult : '点击下方按钮开始抽奖')
.fontSize(18)
.fontColor('#FF5722')
.margin({ bottom: 20 })
// 生肖卡片网格
Grid() {
ForEach(this.zodiacList, (item: string) => {
GridItem() {
Column() {
Text(item)
.fontSize(28)
.fontWeight(FontWeight.Bold)
}
.width(70)
.height(70)
.backgroundColor(this.currentResult === item ? '#FFD54F' : '#ECEFF1')
.borderRadius(12)
.justifyContent(FlexAlign.Center)
}
}, (item: string) => item)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.columnsGap(15)
.rowsGap(15)
.width('90%')
.height(280)
// 抽奖按钮
Button('抽奖')
.width('60%')
.height(48)
.margin({ top: 30 })
.onClick(() => {
this.drawLottery();
})
}
.width('100%')
.height('100%')
}
}
注意Grid的columnsTemplate是个关键点,'1fr 1fr 1fr 1fr'表示4列均分。这里面fr是权重单位,跟CSS的flex-grow概念一致。初学者容易在这里栽坑,写成'100 100 100 100'或者直接给固定像素,都会导致适配出问题。我的建议是:凡是列表类、网格类的布局,都用fr或百分比,别用固定宽度,尤其是要考虑不同屏幕尺寸的手机。
2.2 状态管理机制:为什么改数据界面就变了
这是整个鸿蒙开发里最核心的概念。你把页面想象成一个"函数",输入是状态,输出是UI。当状态变量发生变化时,UI会自动重新渲染,不需要你手动去操作DOM或View。这在ArkUI里就是@State装饰器干的事情。
比如上面代码里的currentResult,我用它来记录当前抽中的生肖:
typescript复制@State currentResult: string = '';
为什么一定要加@State?因为普通变量在内存里变了,UI是感知不到的。只有被@State装饰的变量,系统才帮你建立了"变量→UI"的依赖关系。当this.currentResult = '龙'执行的那一刻,所有绑定了currentResult的UI组件会自动刷新。这个思维转换特别重要,我刚从传统Android开发转过来的时候,总想着去findViewById然后手动setText,在鸿蒙里这完全是多此一举。
再看一眼代码里的卡片背景色:
typescript复制.backgroundColor(this.currentResult === item ? '#FFD54F' : '#ECEFF1')
这行就是典型的"数据驱动UI"。不用你去遍历所有卡片然后逐个改颜色,你只需要声明"如果这张卡是当前结果,就显示金色,否则显示灰色",剩下的系统帮你搞定。
2.3 抽奖逻辑与随机数边界处理
抽奖逻辑本身不复杂,但有几个细节值得单独拿出来说。先看最简单的版本:
typescript复制drawLottery(): void {
let randomIndex = Math.floor(Math.random() * this.zodiacList.length);
this.currentResult = this.zodiacList[randomIndex];
this.historyList.unshift(this.currentResult);
this.saveHistory();
}
由这五行代码,其实引出了两个容易出问题的地方:
第一,随机数的范围。Math.random()生成的是0到1之间的浮点数,乘以数组长度12之后得到0到11.999...,再用Math.floor()向下取整,结果就是0到11的整数,正好对应数组下标。如果写成Math.ceil(),会得到1到12,然后访问zodiacList[12]就会越界返回undefined,UI上直接崩。这种边界问题看着小,实际debug起来很烦,建议一行代码一个变量,把逻辑拆开写。
第二,是否需要排除重复。如果产品需求是"12张卡抽完才重新洗牌"(类似盲盒不重复),那逻辑就要改。需要维护一个剩余卡池数组,抽中后从数组里splice掉,池子空了再重置。但这个项目我定位是"每次独立随机",所以不做排除,更贴近线上抽奖的真实概率感受。
2.4 本地历史记录持久化方案
第一版做成纯内存记录很简单,App一关就没了。如果想做得完整一点,我建议用鸿蒙自带的@ohos.data.preferences(轻量级偏好存储),存字符串数组完全够用。用法非常直白:
typescript复制import dataPreferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';
let context = getContext(this) as common.UIAbilityContext;
let preferences = dataPreferences.getPreferencesSync(context, { name: 'lotteryHistory' });
// 保存
preferences.putSync('history', JSON.stringify(this.historyList));
preferences.flush();
// 读取
let stored = preferences.getSync('history', '[]') as string;
this.historyList = JSON.parse(stored);
这里的核心动作是flush(),不调用它,数据只停留在内存,App一杀就没了。很多新手会漏掉这一步,导致"我明明存了,重启怎么还在"。另外,存储的类型只支持基本类型,数组必须序列化成JSON字符串再存,反过来读取时再parse回来。这个Serialization/Deserialization的过程在鸿蒙里跟Java的Gson用法挺像的,理解了就不难。
3. 实操过程与核心环节实现
3.1 工程创建与项目结构准备
这一步骤虽然基础,但我还是得啰嗦两句,因为工程类型选错后会非常难受。打开DevEco Studio(我用的版本是5.x),新建Project时选Empty Ability模板,Compile SDK选你本机安装好的最新API版本(我这里是API 12)。语言选ArkTS,不要选JavaScript或者C++,因为生命周期模板差别挺大,后面写代码容易找不到对应API。
工程创建好之后,主要的代码都在entry/src/main/ets/pages/Index.ets里。其他目录先不用管,等做到多模块拆分时再碰。
工程建好后,准备12张生肖图片。不需要自己画,可以用文字代替图片,这反而省事。如果你非要用图片,记得放到entry/src/main/resources/base/media目录下,引用方式是$r('app.media.zodiac_mouse'),文件命名只能用小写字母、数字和下划线,不允许大写和横杠。这个坑我踩过,系统会直接编译报错,很莫名其妙。
3.2 抽奖主界面的完整实现
我最终实现的主界面比前面的示例代码完整一些,加入了抽奖状态控制和动画效果。直接贴一版能跑的完整代码,然后逐段解释:
typescript复制@Entry
@Component
struct Index {
@State zodiacList: string[] = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
@State currentResult: string = '';
@State isDrawing: boolean = false;
@State historyList: string[] = [];
private historyKey: string = 'lotteryHistory';
aboutToAppear(): void {
this.loadHistory();
}
build() {
Column() {
Text('生肖卡抽奖')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 30 })
Text('今日运势,试试手气')
.fontSize(14)
.fontColor('#999999')
.margin({ top: 5, bottom: 20 })
// 抽奖结果展示区
Column() {
if (this.currentResult === '') {
Text('点击下方按钮,抽取你的生肖卡')
.fontSize(16)
.fontColor('#999999')
} else {
Text(this.currentResult)
.fontSize(56)
.fontWeight(FontWeight.Bold)
.fontColor('#FF5722')
Text('恭喜抽中生肖' + this.currentResult)
.fontSize(16)
.fontColor('#333333')
.margin({ top: 8 })
}
}
.width('90%')
.height(160)
.backgroundColor('#F5F5F5')
.borderRadius(16)
.justifyContent(FlexAlign.Center)
.margin({ bottom: 20 })
// 卡片网格
Grid() {
ForEach(this.zodiacList, (item: string) => {
GridItem() {
Column() {
Text(item)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor(this.currentResult === item ? '#FF5722' : '#333333')
}
.width('90%')
.aspectRatio(1)
.backgroundColor(this.currentResult === item ? '#FFE0B2' : '#FFFFFF')
.borderRadius(16)
.border({
width: this.currentResult === item ? 2 : 1,
color: this.currentResult === item ? '#FF5722' : '#E0E0E0'
})
.shadow({
radius: this.currentResult === item ? 12 : 4,
color: this.currentResult === item ? '#33FF5722' : '#11000000',
offsetX: 0,
offsetY: 2
})
.justifyContent(FlexAlign.Center)
.animation({
duration: 300,
curve: Curve.EaseOut
})
}
}, (item: string) => item)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.width('92%')
.height(320)
// 抽奖按钮
Button(this.isDrawing ? '抽奖中...' : '开始抽奖')
.width('70%')
.height(50)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.backgroundColor(this.isDrawing ? '#BDBDBD' : '#FF5722')
.borderRadius(25)
.margin({ top: 30 })
.enabled(!this.isDrawing)
.onClick(() => {
this.startDraw();
})
// 历史记录展示
if (this.historyList.length > 0) {
Text('最近抽奖记录')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ top: 25, bottom: 10 })
Scroll() {
Column() {
ForEach(this.historyList.slice(0, 8), (item: string, index: number) => {
Row() {
Text(String(index + 1) + '.')
.fontSize(14)
.fontColor('#999999')
Text(item)
.fontSize(14)
.fontColor('#333333')
.margin({ left: 10 })
Blank()
Text(('已保存'))
.fontSize(12)
.fontColor('#4CAF50')
}
.width('100%')
.height(36)
.padding({ left: 20, right: 20 })
}, (item: string) => item)
}
}
.width('100%')
.height(200)
.align(Alignment.Top)
}
}
.width('100%')
.height('100%')
.backgroundColor('#FAFAFA')
}
startDraw(): void {
if (this.isDrawing) {
return;
}
this.isDrawing = true;
// 模拟一个短暂的抽奖过程,提升体验
setTimeout(() => {
let randomIndex = Math.floor(Math.random() * this.zodiacList.length);
this.currentResult = this.zodiacList[randomIndex];
this.historyList.unshift(this.currentResult);
this.saveHistory();
this.isDrawing = false;
}, 500);
}
saveHistory(): void {
let context = getContext(this) as common.UIAbilityContext;
let preferences = dataPreferences.getPreferencesSync(context, { name: 'lotteryHistory' });
preferences.putSync(this.historyKey, JSON.stringify(this.historyList));
preferences.flush();
}
loadHistory(): void {
let context = getContext(this) as common.UIAbilityContext;
let preferences = dataPreferences.getPreferencesSync(context, { name: 'lotteryHistory' });
let stored = preferences.getSync(this.historyKey, '[]') as string;
try {
this.historyList = JSON.parse(stored);
} catch (e) {
this.historyList = [];
}
}
}
这版代码里,我特别想讲三个点,都是新手容易忽略的细节。
第一个是isDrawing状态。抽奖动作虽然只有一瞬间,但如果用户快速连点按钮,会产生多次随机结果,体验很糟糕。我加了一个isDrawing标志位,在抽奖期间置为true,按钮变为不可点击,等结果出来再恢复。这本质上是一个"防重复提交"逻辑,以后做任何带请求或带延时操作的页面都会用到。这里用setTimeout模拟了500毫秒的"抽奖过程",既让用户有心理预期,又给了UI动画渲染的时间。
第二个是ForEach的key生成问题。ForEach这个组件的第三个参数是一个匿名函数,用来生成key。我在代码里写的(item: string) => item,意思是直接用元素内容当key。这样写最简单,但如果列表里有重复元素(比如历史记录里出现两次"龙"),key就会冲突,导致组件复用错乱。历史记录那块,我用的是ForEach(this.historyList.slice(0, 8), ..., (item: string) => item),这里其实有隐患:如果历史记录里有重复生肖,key就重复了。正确做法是用索引组成唯一key:(item: string, index: number) => index.toString()。这个点官方文档里写得比较隐晦,实际开发中踩到的人非常多。
第三个是动画的过渡效果。我给每个GridItem加了.animation({ duration: 300, curve: Curve.EaseOut }),意思是"当这个组件的属性发生变化时,用300毫秒的缓动动画过渡"。比如背景色从白色变成金色,不会突然跳变,而是平滑过渡。这是ArkUI里做隐式动画最简单的方式。但要注意,这里动画生效有个前提:变化的属性必须是被状态变量驱动的。如果你的颜色是写死的常量,那永远不会触发动画。
3.3 历史记录持久化的完整实现
前面代码里已经写了saveHistory()和loadHistory(),这里单独拎出来再说两个注意点。
一个是生命周期时机。什么时候加载历史记录?我在aboutToAppear()里调用loadHistory(),这个生命周期函数在页面即将显示时执行,相当于传统Android里的onCreate()。注意别在build()方法里同步读取Preferences,因为build是渲染函数,应该只负责输出UI。IO操作放生命周期函数里是最稳妥的。
另一个是JSON解析的容错。存储的数据是个JSON字符串,如果之前存储时崩溃过,数据可能是残缺的。所以解析的时候我用try/catch包了一层,解析失败就默认给空数组。这种防御性编程对学习项目来说可能有点过度设计,但真实工作中,数据的完整性永远不会100%可靠,多这一层保护至少不会白屏崩应用。
3.4 真机调试与模拟器运行验证
代码写完,接下来要跑起来验证。鸿蒙开发现在有模拟器和真机两种方式。模拟器方面,需要注意鸿蒙模拟器目前只能在ARM64架构的电脑上运行,如果你是x86的Windows电脑,很遗憾模拟器是跑不起来的,只能走真机调试。这个限制在热词里也提到了,很多人一开始不知道,环境配了一整天结果卡在这一步,非常崩溃。
真机调试的方式比较直接:手机开启开发者模式,用USB连接电脑,把DevEco Studio识别到的设备选成真机,点击Run按钮就会自动签名安装。签名方面,DevEco Studio会自动帮你生成调试用的证书,不需要手动配置。
运行起来之后,重点验证这四件事:页面有没有正常渲染12张卡?抽奖点十次,结果是不是有变化?抽中的卡有没有高亮和动画?杀掉App重进,历史记录还在不在?这四点过了,Day02的验收就算通过了。
4. 常见问题与排查技巧实录
4.1 模拟器无法启动或运行报错
这个问题搜索热词里出现得很高频,说明是普遍现象。核心原因就一个:鸿蒙模拟器目前只能运行在ARM64架构的机器上。你可以在命令行输入uname -m看看自己的电脑架构,输出是aarch64说明是ARM,是x86_64说明是Intel阵营。
如果电脑不满足条件,建议直接上真机。不要在这个环节死磕,因为没有解决方案,只有绕过方案。另外还有一类报错提示是"运行设备不兼容,鸿蒙模拟器目前只能在arm64平台运行jsvm",我看热词里有这一条,就是这个意思。
4.2 图片资源不显示或编译报错
如果你是用了图片而不是文字代替,最常见的问题有两个:一是资源文件名包含大写字母或横杠,鸿蒙的资源编译器只接受小写字母、数字和下划线,必须按规范重命名;二是引用路径写错,习惯用$r('app.media.xxx')语法,但有人会手滑写成$rawfile('xxx'),这俩的定位不一样:$r编译期就会做资源检查,写错了直接编译失败,$rawfile是运行时读取原始文件,路径错了要等运行才暴露。学习阶段建议用$r,编译期报错比运行期黑屏好排查。
4.3 卡片不刷新或动画不生效
状态变量已经重新赋值了,但界面没反应,这个问题的排查优先级要按顺序来。
先确认这个变量有没有@State装饰。很多新手把变量声明成普通成员变量,赋值后页面纹丝不动,就是缺了装饰器。再确认你有没有在多个子组件之间传递状态,如果子组件需要修改父组件的状态,那么子组件里应该用@Link而不是@Prop,因为@Prop是单向的,子组件改了不会传回去。最后再检查动画问题,动画不生效通常是因为你给组件设置属性时没有走状态变量驱动,而是直接写了常量值。
4.4 ForEach列表重复数据导致渲染异常
前面提过,ForEach的key重复会导致组件复用混乱。具体表现是:某一张卡片的内容被另一张覆盖,或者历史记录列表出现闪跳。排查办法:检查每一项的key是不是唯一的。如果数据本身可能重复(比如历史记录里有两条"龙"),那就用索引组合key:
typescript复制ForEach(this.historyList, (item: string, index: number) => {
// ...
}, (item: string, index: number) => index.toString())
但这里有个隐含的坑:如果用索引做key,当列表头部插入新数据时,所有index都会变化,会导致整个列表重新渲染,性能反而下降。对历史记录这种短列表来说无所谓,长列表要注意。更好的办法是给每条记录生成一个唯一ID(比如时间戳字符串),用ID做key。
4.5 Preferences数据保存失败或读取为空
这个问题通常出在两个地方。第一是忘记调用flush(),只调用了putSync(),数据没有真正落盘,App一重启就丢了。flush()是异步的,但返回一个Promise,你可以不管它,系统会在后台完成写入。第二是存储的key和读取的key不一致,比如保存时用的'history',读取时写的'historyList',结果每次读取都是空。key的命名建议定义成常量,不要散落在代码各处。
这里再给个经验:调试Preferences相关内容时,不用每次手动杀App看数据还在不在,直接用DevEco Studio的Device File Browser,找到/data/app/el2/100/base/<包名>/haps/entry/files/路径下的偏好设置文件,直接打开看内容,效率高很多。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 模拟器无法启动 | x86架构不支持JSVM | 使用ARM64设备或真机调试 |
| 编译报错resource not found | 图片文件名含大写/横杠 | 改成小写字母、数字、下划线 |
| 页面数据变了但UI不变 | 变量没有@State装饰 | 增加@State装饰器 |
| 子组件修改数据父组件不刷新 | 用了单向@Prop | 改为@Link双向绑定 |
| ForEach渲染错乱/跳闪 | key重复 | 用唯一ID或index组合key |
| Preferences重启后数据丢失 | 忘记flush() | 调用flush()落盘 |
| 读取历史记录永远是空 | key不一致 | 统一key常量命名 |
| 点击按钮快速多次触发 | 缺少防重复逻辑 | 添加isDrawing标志位 |
5. 从Day02往后还能怎么扩展
这个项目拿到手之后,你可以自己尝试做几个方向的升级。第一个是概率控制,比如让某些生肖的中奖概率更高,可以自己实现一个加权随机算法,这就跟真实业务里的"活动概率配置"接轨了。第二个是抽卡动画,现在是简单的颜色过渡,可以改成翻牌、旋转甚至跑马灯效果,这需要用到显式动画(animateTo)和转场动画(transition),是鸿蒙动画体系里比较有嚼头的一块。第三个是跨页面跳转,比如点击历史记录里的某一条,跳转到详情页展示这个生肖的性格和运势描述,这就涉及页面路由管理了,是下一个阶段必学的知识点。
我个人做这个小项目最大的收获是,终于理解了什么是"数据驱动UI"。以前写小程序或者传统Android,总是习惯拿到数据后手动操作组件,改一个text要findViewById一次。鸿蒙的声明式范式逼着你换一种思路:把UI当成状态的函数,界面上的每一次变化都来自状态的变化。这种思维一旦建立起来,后面学什么组件、什么动画都会突飞猛进,因为你会发现所有东西都围绕同一个原则在运转。
如果你卡在某个地方超过半小时,我建议别硬刚,先休息一下。鸿蒙的报错信息有时候看了只会让人更懵,但过一会儿回来,发现就是漏了一个标点符号。保持手感,明天Day03继续。
