鸿蒙开发日记:用 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 弧度)。

画扇形的思路:

  1. 移动到圆心
  2. 画圆弧
  3. 关闭路径(自动连回圆心)
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()

理解:先把坐标系的原点移到圆心,再旋转坐标系,这样文字就是沿着半径方向的了。


第三天:让它转起来

动画原理

让转盘转起来,其实就是不断改变它的角度,然后重绘。

我用的方案:

  1. 设置一个初始速度
  2. 每帧增加角度
  3. 速度逐渐减小(减速)
  4. 速度小于某个值时停止
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 弧度,问:指针指向哪个格子?

我的推导过程

假设转盘顺时针转了 θ 弧度:

  1. 指针相对于转盘的位置:-π/2 - θ
  2. 除以每格的角度,得到"第几格":(-π/2 - θ) / (π/4)
  3. 但这个数可能是负数或超过8,所以取模
  4. 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 的声明式语法确实简洁。


心得体会

技术上

  1. Canvas 不难,但要有耐心:各种坐标变换、角度计算,一个错就全错
  2. 动画就是状态的连续变化:理解了这一点,动画就不神秘了
  3. 数学很重要:取模、角度、坐标系,都是基本功

项目上

  1. 先画静态,再做动态:先把转盘画出来,再让它转
  2. 一步步来:别想一口气把所有功能都做完
  3. 多测试:尤其是边界情况,比如负数取模

后续改进

这个项目还有很多可以优化的地方:

  1. 音效:转盘转动时加点声音,更有感觉
  2. 粒子效果:中奖时放个烟花
  3. 概率配置:让后台能设置中奖概率
  4. 保存历史:用 Preferences 持久化存储

留着以后慢慢迭代吧。


开发时间:2天
代码行数:~350行

希望能给正在学鸿蒙 Canvas 的同学一点参考。有问题欢迎留言讨论!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐