鸿蒙开发日记:用 Canvas 撸了个抽奖转盘
鸿蒙开发日记:用 Canvas 撸了个抽奖转盘
缘起
上周公司年会,有个抽奖环节用的是转盘。当时我就想:这玩意儿我能做啊!正好最近在学鸿蒙开发,Canvas 这一课还没补上,索性就撸一个出来练练手。
说干就干,周末两天时间,从零开始写了个"幸运大转盘"。虽然中间踩了不少坑,但最终效果还挺满意的。
这篇文章就当是我的开发日记,记录一下整个过程。
第一天:Canvas 入门,被坐标系搞懵
Canvas 是个啥?
简单说,Canvas 就是一块画布,你可以用代码在上面画画。在鸿蒙里,Canvas 的 API 和 Web 基本一致,所以会前端的同学上手很快。
Canvas(this.canvasCtx)
.width(310)
.height(310)
.onReady(() => {
this.drawWheel()
})
就这么几行,一个 310x310 的画布就准备好了。
第一个坑:坐标系
我一开始以为 Canvas 的原点在左上角,y 轴向下。结果画了个圆,发现位置不对劲。后来才明白,Canvas 的坐标系确实是这样的:
(0,0) ────────► x
│
│
▼
y
所以圆心坐标 (155, 155) 是在画布正中央,这个得想清楚。
画个圆试试
先画个简单的,测试一下:
drawWheel(): void {
const ctx = this.canvasCtx
ctx.clearRect(0, 0, 310, 310) // 清空画布
ctx.beginPath()
ctx.arc(155, 155, 138, 0, Math.PI * 2) // 画圆
ctx.fillStyle = '#FF6B6B'
ctx.fill()
}
效果:一个红色大圆出现在画布中央。
成就感:+1
第二天:画转盘,扇形是关键
怎么画扇形?
转盘是由8个扇形组成的,每个扇形占 45 度(π/4 弧度)。
画扇形的思路:
- 移动到圆心
- 画圆弧
- 关闭路径(自动连回圆心)
ctx.beginPath()
ctx.moveTo(cx, cy) // 移动到圆心
ctx.arc(cx, cy, r, start, end) // 画弧
ctx.closePath() // 闭合路径,形成扇形
ctx.fillStyle = color
ctx.fill()
画8个扇形
const SEGMENT_COLORS = [
'#FF6B6B', '#FFB347', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'
]
for (let i = 0; i < 8; i++) {
const start = i * Math.PI / 4
const end = start + Math.PI / 4
ctx.beginPath()
ctx.moveTo(155, 155)
ctx.arc(155, 155, 138, start, end)
ctx.closePath()
ctx.fillStyle = SEGMENT_COLORS[i]
ctx.fill()
}
效果:8 个彩色扇形拼成一个圆,看着已经有转盘的样子了!
第二个坑:文字旋转
扇形画好了,但文字怎么写?每个扇形的文字方向不一样,总不能都横着写吧?
这里用了 Canvas 的坐标变换:
ctx.save()
ctx.translate(cx, cy) // 把原点移到圆心
ctx.rotate(start + Math.PI / 8) // 旋转到扇形中间
ctx.textAlign = 'right' // 文字对齐方式
ctx.fillText('一等奖', r - 14, 0) // 沿半径方向写文字
ctx.restore()
理解:先把坐标系的原点移到圆心,再旋转坐标系,这样文字就是沿着半径方向的了。
第三天:让它转起来
动画原理
让转盘转起来,其实就是不断改变它的角度,然后重绘。
我用的方案:
- 设置一个初始速度
- 每帧增加角度
- 速度逐渐减小(减速)
- 速度小于某个值时停止
startSpin(): void {
let velocity = 0.35 + Math.random() * 0.15 // 初始速度,带随机
const decel = 0.978 // 衰减系数
this.spinTimerId = setInterval(() => {
this.wheelAngle += velocity
velocity *= decel // 速度衰减
this.drawWheel() // 重绘
if (velocity < 0.002) {
clearInterval(this.spinTimerId)
this.determineResult() // 判断结果
}
}, 20)
}
第三个坑:定时器没清理
我第一次测试的时候,转盘停了之后,我按返回键退出页面。结果再进来,转盘自己开始转了!
原因:定时器没有被清理,还在后台跑着。
解决方法:
aboutToDisappear(): void {
if (this.spinTimerId !== -1) {
clearInterval(this.spinTimerId)
this.spinTimerId = -1
}
}
在页面销毁时,一定要清理定时器!
第四天:判断结果,数学是个好东西
问题是这样的
转盘停了,怎么知道中了什么奖?
指针在正上方(Canvas 坐标系里的 -π/2 位置),转盘转了 wheelAngle 弧度,问:指针指向哪个格子?
我的推导过程
假设转盘顺时针转了 θ 弧度:
- 指针相对于转盘的位置:
-π/2 - θ - 除以每格的角度,得到"第几格":
(-π/2 - θ) / (π/4) - 但这个数可能是负数或超过8,所以取模
- JavaScript 的
%可能得到负数,所以要修正
determineResult(): void {
const pointerAngle = -Math.PI / 2
let raw = (pointerAngle - this.wheelAngle) / (Math.PI / 4)
// 关键:修正负数
let idx = ((raw % 8) + 8) % 8
idx = Math.floor(idx)
this.result = this.items[idx]
}
第四个坑:负数取模
在 JavaScript 里,-3 % 8 的结果是 -3,不是 5!
所以要这样修正:
let idx = ((raw % 8) + 8) % 8
这个技巧我之前在 LeetCode 上做过,没想到在这里用上了。
第五天:UI 美化,颜值即正义
配色方案
我选了8个鲜艳的颜色,看着比较喜庆:
const COLORS = [
'#FF6B6B', // 珊瑚红
'#FFB347', // 橙黄
'#4ECDC4', // 青绿
'#45B7D1', // 天蓝
'#96CEB4', // 薄荷绿
'#FFEAA7', // 柠檬黄
'#DDA0DD', // 粉紫
'#98D8C8' // 浅绿
]
中心圆
加个渐变的中心圆,看着更有立体感:
const grad = ctx.createRadialGradient(155, 155, 0, 155, 155, 24)
grad.addColorStop(0, '#FFFFFF')
grad.addColorStop(1, '#F0F0F0')
ctx.arc(155, 155, 24, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
指针
画个三角形指针,再加点阴影:
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.shadowColor = 'rgba(0,0,0,0.2)'
ctx.shadowBlur = 6
ctx.fill()
第六天:加功能,自定义选项
光有默认奖项太单调了,加个编辑功能:
openEditor(): void {
this.editText = this.items.join('\n')
this.showEditor = true
}
saveItems(): void {
const lines = this.editText
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
if (lines.length < 2) return // 至少2项
this.items = lines.slice(0, 8) // 最多8项
this.showEditor = false
this.drawWheel()
}
UI 用了个弹层:
if (this.showEditor) {
Column() {
Column() {
Text('✏️ 自定义选项')
Text('每行一项,最多8项')
TextArea({ text: this.editText })
.onChange((val) => { this.editText = val })
Row() {
Button('取消')
Button('保存')
}
}
.padding(20)
}
.backgroundColor('#80000000') // 半透明遮罩
}
第七天:历史记录,给用户反馈
抽奖结果应该能查,不然抽完就忘了:
@State history: string[] = []
// 添加记录
this.history = [prize, ...this.history].slice(0, 50)
// 显示记录
ForEach(this.history.slice(0, 6), (item, idx) => {
Row() {
Text(`#${idx + 1}`)
Text('🎯')
Text(item)
}
})
最多保留50条,显示最近6条。简单实用。
运行效果

踩坑总结
| 坑 | 表现 | 解决 |
|---|---|---|
| Canvas 坐标系 | 画的位置不对 | 理解原点在左上角,y轴向下 |
| 文字旋转 | 文字都是横着的 | translate + rotate 变换坐标系 |
| 定时器泄漏 | 退出后还在转 | 页面销毁时清理定时器 |
| 负数取模 | 结果索引错误 | (raw % 8 + 8) % 8 修正 |
| 文字过长 | 超出扇形 | 手动截断,加省略号 |
最终代码量
主文件 Index.ets:约 350 行
- Canvas 绑制:~100 行
- 动画逻辑:~50 行
- UI 布局:~200 行
对于这样一个完整的应用,这个代码量不算大。ArkTS 的声明式语法确实简洁。
心得体会
技术上
- Canvas 不难,但要有耐心:各种坐标变换、角度计算,一个错就全错
- 动画就是状态的连续变化:理解了这一点,动画就不神秘了
- 数学很重要:取模、角度、坐标系,都是基本功
项目上
- 先画静态,再做动态:先把转盘画出来,再让它转
- 一步步来:别想一口气把所有功能都做完
- 多测试:尤其是边界情况,比如负数取模
后续改进
这个项目还有很多可以优化的地方:
- 音效:转盘转动时加点声音,更有感觉
- 粒子效果:中奖时放个烟花
- 概率配置:让后台能设置中奖概率
- 保存历史:用 Preferences 持久化存储
留着以后慢慢迭代吧。
开发时间:2天
代码行数:~350行
希望能给正在学鸿蒙 Canvas 的同学一点参考。有问题欢迎留言讨论!
更多推荐


所有评论(0)