欢迎光临
我们一直在努力

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)
    未经允许不得转载:171主机测评 » HarmonyOS NEXT 实战:Canvas 绘制幸运大转盘抽奖应用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址