HarmonyOS NEXT 实战:Canvas 绘制幸运大转盘抽奖应用
HarmonyOS NEXT 实战:Canvas 绘制幸运大转盘抽奖应用
包名:
com.example.myapplication
API版本:HarmonyOS NEXT (API 23+)
开发工具:DevEco Studio
一、前言:为什么要做抽奖转盘?
在各类营销活动、年会抽奖、课堂互动场景中,抽奖转盘是最常见的互动形式之一。作为一名鸿蒙开发者,我一直想深入研究 Canvas 绑制技术,而抽奖转盘正好是一个绝佳的练手项目——它涉及:
- Canvas 2D 绑制:扇形、渐变、阴影、文字旋转
- 动画系统:减速运动、定时器控制
- 状态管理:旋转状态、结果展示、历史记录
- 用户交互:自定义选项、弹层编辑
本文将详细记录从设计到实现的完整过程,希望能帮助正在学习鸿蒙 Canvas 开发的同学少走弯路。
二、功能设计
2.1 核心功能
| 功能 | 说明 |
|---|---|
| 转盘绘制 | 8 格彩色扇形,Canvas 绑制 |
| 抽奖动画 | 减速旋转,自然停止 |
| 结果展示 | 动画结束后弹出中奖结果 |
| 自定义选项 | 用户可编辑8个奖项内容 |
| 历史记录 | 保存最近50条抽奖记录 |
2.2 技术选型
- Canvas 组件:鸿蒙提供的 CanvasRenderingContext2D API,与 Web Canvas 高度兼容
- 定时器动画:使用
setInterval控制帧更新,模拟物理减速 - 状态驱动 UI:
@State装饰器管理全部状态,声明式渲染
2.3 UI 设计稿
┌─────────────────────────────┐
│ 🎡 幸运大转盘 [✏️ 编辑] │ 标题栏
├─────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ 🔻 │ │ 指针
│ └─────────┘ │
│ ╭─────────────╮ │
│ ╱ 一等奖 ╲ │
│ │ 二等奖 三等奖 │ 转盘
│ │ 再接 好运 │
│ ╲ 再厉 鼓励奖 ╱ │
│ ╰─────────────╯ │
│ │
│ [🎯 抽奖] │ 按钮
│ │
│ 🎉 恭喜您获得 │
│ 一等奖 │ 结果
│ │
│ 📋 抽奖记录 │
│ #01 🎯 一等奖 │ 历史
│ #02 🎯 三等奖 │
└─────────────────────────────┘
三、项目初始化
3.1 创建项目
打开 DevEco Studio,选择 Empty Ability 模板:
- 项目名称:MyApplication
- Bundle Name:com.example.myapplication
- Compile SDK:API 23(HarmonyOS NEXT)
- Model:Stage 模型
3.2 项目结构
MyApplication/
├── AppScope/
│ ├── app.json5 # 应用配置
│ └── resources/base/
│ └── element/string.json # 全局字符串
├── entry/
│ └── src/main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets
│ │ └── pages/
│ │ └── Index.ets # 主页面(核心代码)
│ ├── resources/
│ │ └── base/element/
│ │ └── string.json # 模块字符串
│ └── module.json5
└── build-profile.json5
四、核心代码实现
4.1 状态变量定义
@Entry
@Component
struct LuckySpinWheel {
// 转盘选项
@State items: string[] = [...DEFAULT_ITEMS]
// 旋转角度(弧度)
@State wheelAngle: number = 0
// 是否正在旋转
@State isSpinning: boolean = false
// 抽奖结果
@State result: string = ''
@State showResult: boolean = false
// 历史记录
@State history: string[] = []
// 编辑器状态
@State showEditor: boolean = false
@State editText: string = ''
// Canvas 上下文
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D()
// 定时器 ID
private spinTimerId: number = -1
// 常量配置
private readonly CANVAS_SIZE: number = 310
private readonly CX: number = 155 // 圆心 X
private readonly CY: number = 155 // 圆心 Y
private readonly WHEEL_RADIUS: number = 138
private readonly SEG_COUNT: number = 8
}
设计思路:
wheelAngle:记录当前转盘旋转角度,范围[0, 2π)isSpinning:防止重复点击,控制按钮状态history:使用数组存储历史记录,新记录插入头部
4.2 配色方案
// 转盘配色(8 种鲜艳颜色交替)
const SEGMENT_COLORS: string[] = [
'#FF6B6B', '#FFB347', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'
]
// 对应的文字颜色(浅色背景用深字,深色背景用浅字)
const SEGMENT_TEXT_COLORS: string[] = [
'#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF',
'#333333', '#333333', '#333333', '#333333'
]
// 默认奖项
const DEFAULT_ITEMS: string[] = [
'一等奖', '二等奖', '三等奖', '幸运奖',
'再接再厉', '好运', '鼓励奖', '参与奖'
]
配色技巧:
- 使用高饱和度颜色,视觉效果更醒目
- 前4个是深色背景配白字,后4个是浅色背景配深字
- 保证文字可读性的同时保持美观
五、Canvas 绑制详解
5.1 Canvas 组件初始化
Canvas(this.canvasCtx)
.width(this.CANVAS_SIZE)
.height(this.CANVAS_SIZE)
.onReady(() => {
this.drawWheel()
})
.margin({ top: 5 })
关键点:
onReady:Canvas 准备就绪后回调,此时可以开始绑制CanvasRenderingContext2D:2D 绑制上下文,API 与 Web Canvas 一致
5.2 绘制转盘主体
drawWheel(): void {
const ctx: CanvasRenderingContext2D = this.canvasCtx
const cx: number = this.CX
const cy: number = this.CY
const r: number = this.WHEEL_RADIUS
const n: number = this.SEG_COUNT
const segAng: number = this.segAngle // 每格角度 = 2π/8
const angle: number = this.wheelAngle
// 清空画布
ctx.clearRect(0, 0, this.CANVAS_SIZE, this.CANVAS_SIZE)
// 1. 绘制外圈阴影
ctx.beginPath()
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
ctx.shadowColor = 'rgba(0,0,0,0.15)'
ctx.shadowBlur = 12
ctx.fillStyle = '#FFFFFF'
ctx.fill()
ctx.shadowBlur = 0
// 2. 绘制每一格扇形
for (let i = 0; i < n; i++) {
const start: number = angle + i * segAng
const end: number = start + segAng
const colorIdx: number = i % SEGMENT_COLORS.length
// 绘制扇形
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.arc(cx, cy, r, start, end)
ctx.closePath()
ctx.fillStyle = SEGMENT_COLORS[colorIdx]
ctx.fill()
ctx.strokeStyle = '#FFFFFF'
ctx.lineWidth = 2
ctx.stroke()
// 3. 绘制文字标签
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(start + segAng / 2)
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
ctx.font = '15px HarmonyOS Sans'
ctx.fillStyle = SEGMENT_TEXT_COLORS[colorIdx]
const label: string = this.items[i] || ''
const displayText: string = label.length > 6
? label.slice(0, 5) + '…'
: label
ctx.fillText(displayText, r - 14, 0)
ctx.restore()
}
// ... 继续绘制中心圆和指针
}
绘制步骤解析:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | clearRect |
清空上一帧内容 |
| 2 | arc + shadowBlur |
绘制外圈白色底座 + 阴影 |
| 3 | 循环绘制8个扇形 | 使用不同颜色填充 |
| 4 | 文字旋转 | translate + rotate 让文字沿半径方向 |
扇形绘制原理:
ctx.moveTo(cx, cy) // 移动到圆心
ctx.arc(cx, cy, r, start, end) // 画圆弧
ctx.closePath() // 自动连接回圆心,形成扇形
5.3 绘制中心圆
// 径向渐变
const grad: CanvasGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, 24)
grad.addColorStop(0, '#FFFFFF')
grad.addColorStop(1, '#F0F0F0')
ctx.beginPath()
ctx.arc(cx, cy, 24, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
ctx.strokeStyle = '#E0E0E0'
ctx.lineWidth = 2
ctx.stroke()
// 中心文字
ctx.fillStyle = '#FF6B35'
ctx.font = 'bold 14px HarmonyOS Sans'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('LUCKY', cx, cy - 1)
渐变效果:
createRadialGradient(x0, y0, r0, x1, y1, r1):创建径向渐变- 从圆心白色(
#FFFFFF)渐变到边缘浅灰(#F0F0F0) - 比纯色更有立体感
5.4 绘制指针
const px: number = cx
const py: number = cy - r - 8 // 在转盘上方
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.2)'
ctx.shadowBlur = 6
// 指针三角形
ctx.beginPath()
ctx.moveTo(px, py - 16) // 顶部尖角
ctx.lineTo(px - 12, py + 4) // 左下
ctx.lineTo(px + 12, py + 4) // 右下
ctx.closePath()
ctx.fillStyle = '#FF6B35'
ctx.fill()
ctx.strokeStyle = '#E05A2A'
ctx.lineWidth = 1.5
ctx.stroke()
ctx.shadowBlur = 0
ctx.restore()
// 指针小圆装饰
ctx.beginPath()
ctx.arc(px, py - 8, 4, 0, Math.PI * 2)
ctx.fillStyle = '#FF6B35'
ctx.fill()
指针设计:
- 位于转盘正上方(固定不动)
- 三角形指向下方
- 带阴影增加立体感
- 小圆装饰让指针更有设计感
六、旋转动画实现
6.1 动画原理
转盘旋转采用物理减速模型:
- 给定初始角速度
velocity - 每帧更新角度
angle += velocity - 速度衰减
velocity *= decel - 当速度小于阈值时停止
6.2 核心代码
startSpin(): void {
if (this.isSpinning) return // 防止重复点击
this.isSpinning = true
this.showResult = false
this.result = ''
// 初始角速度:0.35 ~ 0.5 弧度/帧
let velocity: number = 0.35 + Math.random() * 0.15
// 衰减系数
const decel: number = 0.978
// 最小速度阈值
const minVelocity: number = 0.002
// 定时器:每 20ms 一帧
this.spinTimerId = setInterval(() => {
// 更新角度
this.wheelAngle = (this.wheelAngle + velocity) % (Math.PI * 2)
// 速度衰减
velocity *= decel
// 重绘转盘
this.drawWheel()
// 判断是否停止
if (velocity < minVelocity) {
this.stopSpin()
this.determineResult()
}
}, 20) // 20ms ≈ 50 FPS
}
参数调优经验:
| 参数 | 初始值 | 效果 |
|---|---|---|
velocity |
0.35~0.5 | 随机化让每次结果不可预测 |
decel |
0.978 | 衰减太快停得太快,太慢等太久 |
minVelocity |
0.002 | 阈值越小停止越精确 |
| 帧间隔 | 20ms | 接近 50FPS,流畅且不过度消耗性能 |
6.3 停止与清理
stopSpin(): void {
this.isSpinning = false
if (this.spinTimerId !== -1) {
clearInterval(this.spinTimerId)
this.spinTimerId = -1
}
}
生命周期管理:
aboutToDisappear(): void {
this.stopSpin() // 页面销毁时清理定时器
}
七、结果判定算法
7.1 问题分析
指针固定在正上方(Canvas 坐标系中的 -π/2 位置),转盘旋转后,如何判断指针指向哪一格?
7.2 算法实现
determineResult(): void {
// 指针在正上方(Canvas 坐标系中为 -π/2)
const pointerAngle: number = -Math.PI / 2
// 计算指针相对于转盘的位置
let raw: number = (pointerAngle - this.wheelAngle) / this.segAngle
// 取模到 [0, SEG_COUNT)
let idx: number = ((raw % this.SEG_COUNT) + this.SEG_COUNT) % this.SEG_COUNT
idx = Math.floor(idx)
// 获取对应奖项
const prize: string = this.items[idx] || '未知'
this.result = prize
this.showResult = true
// 添加到历史记录(最新的在最前面)
this.history = [prize, ...this.history].slice(0, 50)
}
算法解析:
假设转盘顺时针旋转了 wheelAngle 弧度:
pointerAngle - this.wheelAngle:指针相对于转盘的角度- 除以
segAngle:转换为"第几格"(可能是小数或负数) - 取模:确保结果在
[0, 8)范围内 - 向下取整:得到最终索引
为什么需要 + this.SEG_COUNT?
因为 JavaScript 的 % 运算可能得到负数(如 -3 % 8 = -3),加一个周期再取模确保结果为正。
八、自定义选项功能
8.1 打开编辑器
openEditor(): void {
this.editText = this.items.join('\n') // 数组转文本
this.showEditor = true
}
8.2 保存编辑
saveItems(): void {
// 分割、去空格、过滤空行
const lines: string[] = this.editText
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
// 至少 2 项
if (lines.length < 2) {
return
}
// 最多 8 项,不足补默认
const newItems: string[] = lines.slice(0, 8)
while (newItems.length < 8) {
newItems.push(DEFAULT_ITEMS[newItems.length % DEFAULT_ITEMS.length])
}
this.items = newItems
this.showEditor = false
this.wheelAngle = 0
this.showResult = false
this.drawWheel()
}
8.3 编辑器 UI
if (this.showEditor) {
Column() {
Column() {
Text('✏️ 自定义选项')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Text('每行一项,最多 8 项,最少 2 项')
.fontSize(13)
.fontColor('#999999')
TextArea({ text: this.editText })
.height(200)
.backgroundColor('#F9F9F9')
.onChange((val: string) => { this.editText = val })
Row() {
Button('取消')
.onClick(() => { this.showEditor = false })
Button('保存')
.backgroundColor('#FF6B35')
.onClick(() => { this.saveItems() })
}
}
.padding(20)
.backgroundColor('#FFFFFF')
.borderRadius(20)
}
.width('100%')
.height('100%')
.backgroundColor('#80000000') // 半透明遮罩
}
九、历史记录功能
9.1 数据结构
@State history: string[] = [] // 最新记录在数组头部
9.2 添加记录
this.history = [prize, ...this.history].slice(0, 50)
技巧:
[prize, ...this.history]:将新记录插入数组头部.slice(0, 50):最多保留50条,防止内存无限增长
9.3 显示记录
if (this.history.length > 0) {
Column() {
Row() {
Text('📋 抽奖记录')
Blank()
Text('清空')
.fontColor('#FF6B35')
.onClick(() => { this.history = [] })
}
ForEach(
this.history.slice(0, 6), // 只显示最近6条
(item: string, idx: number) => {
Row() {
Text(`#${(idx + 1).toString().padStart(2, '0')}`)
Text('🎯')
Text(item)
}
}
)
}
}
十、完整 UI 布局
10.1 主界面
build() {
Stack() {
// 主界面(可滚动)
Scroll() {
Column() {
// 标题栏
Row() {
Text('🎡 幸运大转盘')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Blank()
Button('✏️ 编辑')
.onClick(() => { this.openEditor() })
}
// Canvas 转盘
Canvas(this.canvasCtx)
.width(this.CANVAS_SIZE)
.height(this.CANVAS_SIZE)
.onReady(() => { this.drawWheel() })
// 抽奖按钮
Button(this.isSpinning ? '🎡 抽奖中...' : '🎯 抽奖')
.width(200)
.height(54)
.backgroundColor(this.isSpinning ? '#CCCCCC' : '#FF6B35')
.enabled(!this.isSpinning)
.onClick(() => { this.startSpin() })
// 结果展示
if (this.showResult) {
Column() {
Text('🎉 恭喜您获得')
Text(this.result)
.fontSize(32)
.fontColor('#FF6B35')
}
}
// 历史记录
if (this.history.length > 0) {
Column() {
Text('📋 抽奖记录')
ForEach(this.history.slice(0, 6), ...)
}
}
}
}
.backgroundColor('#FFF8E7')
// 编辑弹层(覆盖在主界面上)
if (this.showEditor) {
Column() {
// 弹层内容
}
.backgroundColor('#80000000')
}
}
}
10.2 布局技巧
| 技术 | 用途 |
|---|---|
Stack |
层叠布局,弹层覆盖主界面 |
Scroll |
内容超出屏幕时可滚动 |
Blank |
占位符,实现左右布局 |
if 条件渲染 |
动态显示结果和历史 |
十一、运行效果

十二、踩坑记录
12.1 踩坑一:Canvas 绑制时机
问题:在 aboutToAppear 中调用 drawWheel() 报错,提示 Canvas 未初始化。
原因:Canvas 组件此时还未完成初始化,canvasCtx 不可用。
解决:使用 onReady 回调:
Canvas(this.canvasCtx)
.onReady(() => {
this.drawWheel()
})
12.2 踩坑二:定时器未清理
问题:快速点击返回键退出页面,再次进入时转盘自动旋转。
原因:setInterval 的定时器没有被清理,继续在后台执行。
解决:
aboutToDisappear(): void {
this.stopSpin() // 页面销毁时清理定时器
}
12.3 踩坑三:文字截断问题
问题:奖项名称过长时,文字超出扇形区域。
解决:手动截断文字:
const displayText: string = label.length > 6
? label.slice(0, 5) + '…'
: label
12.4 踩坑四:结果判定错误
问题:指针指向的格子与实际结果不一致。
原因:取模运算结果为负数,导致索引错误。
解决:修正取模逻辑:
let idx: number = ((raw % this.SEG_COUNT) + this.SEG_COUNT) % this.SEG_COUNT
12.5 踩坑五:重复点击
问题:快速多次点击"抽奖"按钮,导致多个定时器同时运行。
解决:添加状态判断:
startSpin(): void {
if (this.isSpinning) return // 正在旋转则忽略
this.isSpinning = true
// ...
}
十三、优化建议
13.1 性能优化
- requestAnimationFrame:替代
setInterval,与屏幕刷新率同步 - 离屏 Canvas:预渲染转盘静态部分,减少重绘开销
- 节流防抖:限制 Canvas 重绘频率
13.2 功能扩展
- 音效:旋转时播放音效,增强沉浸感
- 粒子效果:中奖时播放烟花动画
- 概率配置:后台设置各奖项中奖概率
- 分享功能:将结果分享到社交平台
13.3 安全增强
- 加密结果:防止前端篡改中奖结果
- 服务端验证:关键业务由服务端判定
- 日志记录:完整记录抽奖行为
十四、总结
14.1 技术要点回顾
| 知识点 | 应用场景 |
|---|---|
| Canvas 绑制 | 转盘扇形、指针、文字 |
| 径向渐变 | 中心圆立体效果 |
| 坐标变换 | 文字旋转 translate + rotate |
| 定时器动画 | 减速旋转效果 |
| 状态管理 | @State 驱动 UI 更新 |
| 条件渲染 | 结果展示、历史记录、编辑弹层 |
14.2 项目亮点
- ✅ 完整的 Canvas 2D 绑制实践
- ✅ 流畅的减速动画效果
- ✅ 精确的结果判定算法
- ✅ 可自定义奖项内容
- ✅ 历史记录持久化
- ✅ 美观的 UI 设计
14.3 代码统计
- 核心代码:约 350 行(
Index.ets单文件) - Canvas 绘制:约 100 行
- 动画逻辑:约 50 行
- UI 布局:约 200 行
十五、源码附录
完整代码已包含在项目中,关键文件:
entry/src/main/ets/pages/Index.ets # 主页面(全部逻辑)
entry/src/main/resources/base/element/string.json # 字符串资源
AppScope/app.json5 # 应用配置
更多推荐

所有评论(0)