一、引言
抽奖转盘是一种用户参与度高、反馈即时的交互游戏。用户点击"开始抽奖"按钮后,奖品列表快速循环滚动,逐渐减速直至停止在某个奖品上——这种由快到慢的减速动画营造了紧张感和仪式感。
从 ArkTS 技术角度来看,这个应用涉及:
- setTimeout 递归循环:与
setInterval不同,setTimeout递归可以在每次回调中动态调整延迟时间,实现"先快后慢"的减速效果 - 防连点机制:使用
enabled属性在动画运行期间禁用按钮,防止用户在动画进行中重复触发 - 边界生命周期清理:
aboutToDisappear中清理未完成的定时器 - 固定长度历史记录:使用
push+shift维护最多 20 条的中奖记录
本文将从减速动画的数学原理出发,深入setTimeout递归的精妙设计和防连点的最佳实践。
二、完整源码
// index14.ets@Entry@Componentstruct Index14{@Stateprizes:string[]=['🎁 一等奖','🎈 二等奖','🎀 三等奖','🍫 巧克力','☕ 咖啡券','🎬 电影票','📚 笔记本','🍀 好运符'];@StatecurrentIndex:number=0;@StateisSpinning:boolean=false;@Stateresult:string='';@Statehistory:string[]=[];privatespinTimer:number=-1;startSpin():void{if(this.isSpinning)return;this.isSpinning=true;this.result='';letspeed=80;constroll=()=>{this.currentIndex=(this.currentIndex+1)%this.prizes.length;speed+=15;if(speed<500){this.spinTimer=setTimeout(roll,speed);}else{this.isSpinning=false;constprize=this.prizes[this.currentIndex];this.result='🎉 恭喜获得: '+prize;this.history.push(prize);if(this.history.length>20)this.history.shift();}};this.spinTimer=setTimeout(roll,50);}aboutToDisappear():void{if(this.spinTimer>=0)clearTimeout(this.spinTimer);}build(){Column(){Text('🎰 抽奖转盘').fontSize(28).fontWeight(FontWeight.Bold).margin({top:40,bottom:5})Text('点击按钮开始抽奖').fontSize(13).fontColor('#8E8E93').margin({bottom:20})Column(){Text(this.prizes[this.currentIndex]).fontSize(32).fontWeight(FontWeight.Bold).fontColor('#333333').margin({top:35})}.width(220).height(120).backgroundColor('#FFF3E0').borderRadius(20).border({width:3,color:this.isSpinning?'#FF9500':'#FFCC00'}).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center).margin({bottom:15})Button(this.isSpinning?'🎡 抽奖中...':'🎯 开始抽奖').width(180).height(48).fontSize(18).backgroundColor(this.isSpinning?'#8E8E93':'#FF3B30').borderRadius(24).enabled(!this.isSpinning).onClick(()=>{this.startSpin();}).margin({bottom:15})Text(this.result).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF3B30').height(30).margin({bottom:15})if(this.history.length>0){Text('中奖记录 ('+this.history.length+')').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#8E8E93').margin({bottom:6})List({space:4}){ForEach(this.history,(item:string,index:number)=>{ListItem(){Row(){Text('#'+(index+1)).fontSize(12).fontColor('#8E8E93').width(30)Text(item).fontSize(14).fontColor('#333333')}.width('70%').padding({top:4,bottom:4}).justifyContent(FlexAlign.Center).backgroundColor('#F8F8F8').borderRadius(6)}},(item:string,index:number)=>index.toString())}.width('100%').layoutWeight(1)}}.width('100%').height('100%').justifyContent(FlexAlign.Start).alignItems(HorizontalAlign.Center)}}三、核心技术解析
3.1 setTimeout 递归:减速动画的核心
抽奖转盘的动画效果是通过setTimeout递归调用实现的。与setInterval的固定间隔不同,setTimeout每次可以在回调中计算新的延迟时间:
letspeed=80;// 初始延迟:80ms(快速切换)constroll=()=>{this.currentIndex=(this.currentIndex+1)%this.prizes.length;speed+=15;// 每次增加 15ms 延迟(逐渐减速)if(speed<500){// 速度上限:500msthis.spinTimer=setTimeout(roll,speed);}else{this.isSpinning=false;// 停止this.result='🎉 恭喜获得: '+this.prizes[this.currentIndex];this.history.push(this.prizes[this.currentIndex]);if(this.history.length>20)this.history.shift();}};this.spinTimer=setTimeout(roll,50);// 启动减速过程数学分析:
第 n 次 延迟(ms) 奖品索引 1 50 0 → 1 2 65 1 → 2 3 80 2 → 3 4 95 3 → 4 5 110 4 → 5 6 125 5 → 6 7 140 6 → 7 8 155 7 → 0 9 170 0 → 1 10 185 1 → 2 ... ... ... 30 485 6 → 7 31 500 (≥500) 停止!从 50ms 开始,每次增加 15ms,直到达到 500ms 的上限,总共需要约(500-50)/15 = 30次迭代,约耗时30 × 平均 275ms ≈ 8.25 秒。这个时长恰到好处——太短(<3秒)缺乏仪式感,太长(>15秒)用户会不耐烦。
为什么不用setInterval?
// 如果用 setInterval(错误示范)this.timerId=setInterval(()=>{this.currentIndex=(this.currentIndex+1)%this.prizes.length;// ❌ 无法动态调整间隔// ❌ 停止时需要用 clearInterval// ❌ 无法平滑减速},80);// 固定 80ms,不会变慢setInterval的间隔在初始化后就固定了,无法在运行过程中动态调整。而抽奖转盘的核心体验正是"越来越慢"的减速效果,所以必须使用setTimeout递归。
3.2 防连点机制:enabled + isSpinning 双重保护
抽奖转盘最关键的设计之一是防止用户在动画进行中重复点击:
@StateisSpinning:boolean=false;startSpin():void{if(this.isSpinning)return;// 第一道防线:函数入口拦截this.isSpinning=true;// 锁定// ...}// UI 中的第二道防线Button(this.isSpinning?'🎡 抽奖中...':'🎯 开始抽奖').enabled(!this.isSpinning)// isSpinning=true 时按钮灰色不可用双重保护机制:
| 层级 | 方式 | 防范目标 |
|---|---|---|
| 逻辑层 | if (this.isSpinning) return; | 防止代码逻辑重入(如键盘快捷键触发) |
| 展示层 | .enabled(!this.isSpinning) | 防止用户通过 UI 重复点击 |
缺少任何一层都可能出现问题:
- 只有逻辑层,没有 UI 层:按钮样式不变,用户以为没点到,反复点击造成挫败感
- 只有 UI 层,没有逻辑层:如果有其他代码路径调用
startSpin(),可能绕过 UI 限制
3.3 生命周期清理:aboutToDisappear
aboutToDisappear():void{if(this.spinTimer>=0)clearTimeout(this.spinTimer);}当抽奖转盘正在旋转时用户离开页面,setTimeout的回调仍然会在后台执行。如果回调中修改了@State变量,而组件树已被销毁,会导致无效的状态更新甚至内存泄漏。
aboutToDisappear在以下场景被触发:
- 页面跳转:使用
router.pushUrl()导航到其他页面 - 返回上一个页面:使用
router.back() - 应用进入后台:某些情况下系统可能会销毁页面
- 页面强制刷新:如热重载
清理的原则:凡是setTimeout或setInterval返回的 ID,都应当在aboutToDisappear中clearTimeout或clearInterval。
3.4 奖品索引的循环算法
this.currentIndex=(this.currentIndex+1)%this.prizes.length;这是一个经典的循环队列算法:
currentIndex始终在[0, prizes.length - 1]范围内- 当
currentIndex = 7(最后一个奖品)时,(7 + 1) % 8 = 0,回到第一个奖品 - 因此转盘永远"转不完",保持循环
视觉效果:用户看到奖品列表从第一个到最后一个不停轮转,无法预测停止位置——这正是转盘的魅力所在。
3.5 醒目边框状态
.border({width:3,color:this.isSpinning?'#FF9500':'#FFCC00'})抽奖转盘在旋转时使用橙色 ‘#FF9500’ 边框,停止时使用黄色 ‘#FFCC00’ 边框。这是一种视觉反馈,让用户直观地知道转盘的当前状态。在真实的物理转盘中,旋转时往往有指示灯闪烁,这个设计模拟了同样的效果。
四、setTimeout 与 setInterval 的选型指南
在 ArkTS 时间相关应用中,如何选择定时器 API 是一个常见的决策问题:
| 需求 | 推荐 API | 理由 |
|---|---|---|
| 固定间隔重复(如计时器每秒 +1) | setInterval | 简洁,无需递归 |
| 动态调整间隔(如减速动画) | setTimeout递归 | 每次可调整延迟 |
| 一次性延迟(如 2 秒后消失) | setTimeout | 一次性,无需清除 |
| 高精度动画(如 60fps) | requestAnimationFrame | 与屏幕刷新同步 |
| 异步等待 | setTimeout封装 Promise | 可在 async/await 中使用 |
在 ArkTS 中封装一个动态延迟的循环:
classDynamicTimer{privatetimerId:number=-1;start(callback:()=>boolean,getDelay:()=>number):void{constloop=()=>{if(callback()){// callback 返回 false 表示停止this.timerId=setTimeout(loop,getDelay());}};this.timerId=setTimeout(loop,getDelay());}stop():void{if(this.timerId>=0){clearTimeout(this.timerId);this.timerId=-1;}}}这个封装将"执行业务逻辑"和"计算延迟时间"分离,复用性更强。
五、用户体验的细节设计
抽奖转盘中几个提升体验的细节值得注意:
按钮文字随状态变化:
Button(this.isSpinning?'🎡 抽奖中...':'🎯 开始抽奖')在旋转时显示"抽奖中…“,强调"系统正在工作”,减少用户的等待焦虑。
结果文字的动画式出现:
this.result='🎉 恭喜获得: '+prize;结果以 🎉 开头,营造"揭晓时刻"的庆祝氛围。在本例中没有动画,但可以通过进一步增加 CSS 动效来加强。
中奖记录的彩色背景:
.backgroundColor('#FFF3E0')// 浅橙色.border({width:3,color:this.isSpinning?'#FF9500':'#FFCC00'})浅橙色背景搭配金色边框,营造"奖券"的视觉意象。
六、与 index2(待办事项)的历史记录对比
index14 和 index2 都使用了列表展示数据,但设计理念不同:
| 维度 | index2 待办事项 | index14 抽奖转盘 |
|---|---|---|
| 数据来源 | 用户输入 | 自动生成 |
| 列表操作 | 增 + 删 | 只读追加 |
| 最大条目 | 无限制 | 上限 20 |
| 条目展示 | 项目文字 + 删除按钮 | 序号 + 奖品名称 |
| 清除操作 | 逐条删除 / 无批量清空 | 无 |
index14 的历史记录是只读的日志,用户不需要手动删除它;而 index2 的待办事项需要用户主动管理。
七、“伪随机"还是"真随机”?
抽奖转盘使用setTimeout的延迟累积,实际上不是真随机——停止位置完全由 CPU 时间片和事件循环的调度决定。这意味着:
- 无法预判结果:用户无法知道何时减速到 500ms
- 不可作弊:开发者也无法预知停止位置
- 每次不同:不同的 CPU 负载、不同的系统状态都会影响停止位置
但严格来说,这不是均匀随机——如果某个奖品的索引出现某种次数的概率不一致,在数学上是不公平的。商业抽奖系统通常使用加密安全的随机数生成器,并在服务端完成抽奖逻辑。
对于这个 Demo 应用,“伪随机"的体验已经足够——用户感受到的是"不可预测的转盘”,这就达到了目的。
八、扩展方向
- 中奖概率控制:不同奖品有不同的基础概率(一等奖 1%,参与奖 50%)
- 每日免费次数:每日限制抽奖次数,使用本地存储记录
- 转盘扇形 UI:使用
Canvas绘制真正的扇形转盘,而非列表滚动的模拟 - 振动反馈:每次切换奖品时配合振动(使用
@ohos.vibrator) - 音乐音效:旋转时的"嗒嗒"声和揭晓时的"当当"声
- 动画增强:使用
animateTo()实现平滑切换
九、本章小结
| 知识点 | 掌握程度 |
|---|---|
setTimeout递归实现减速动画 | ✅ 理解动态延迟的核心算法 |
| 防连点双重保护 | ✅ 掌握enabled+isSpinning检查 |
aboutToDisappear清理定时器 | ✅ 理解组件生命周期的重要性 |
循环队列(i+1) % n | ✅ 掌握奖品轮转算法 |
| 固定长度 FIFO 历史记录 | ✅ 理解push+shift上限控制 |
抽奖转盘虽然代码量不大,但它的防连点设计和减速动画算法在游戏化应用中反复出现,是 ArkTS 动画编程的重要基础。