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 动画原理

转盘旋转采用物理减速模型

  1. 给定初始角速度 velocity
  2. 每帧更新角度 angle += velocity
  3. 速度衰减 velocity *= decel
  4. 当速度小于阈值时停止

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 弧度:

  1. pointerAngle - this.wheelAngle:指针相对于转盘的角度
  2. 除以 segAngle:转换为"第几格"(可能是小数或负数)
  3. 取模:确保结果在 [0, 8) 范围内
  4. 向下取整:得到最终索引

为什么需要 + 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 性能优化

  1. requestAnimationFrame:替代 setInterval,与屏幕刷新率同步
  2. 离屏 Canvas:预渲染转盘静态部分,减少重绘开销
  3. 节流防抖:限制 Canvas 重绘频率

13.2 功能扩展

  1. 音效:旋转时播放音效,增强沉浸感
  2. 粒子效果:中奖时播放烟花动画
  3. 概率配置:后台设置各奖项中奖概率
  4. 分享功能:将结果分享到社交平台

13.3 安全增强

  1. 加密结果:防止前端篡改中奖结果
  2. 服务端验证:关键业务由服务端判定
  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                    # 应用配置

Logo

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

更多推荐