鸿蒙ArkTS Canvas实战:转盘抽奖程序开发教程(基础到进阶)

#新星杯·14天创作挑战营·第15期#

本期代码仓库: Gitte-Hongmeng Application Development Project Tutorial

随着鸿蒙生态的蓬勃发展,HarmonyOS应用开发已成为众多开发者关注的新热点。ArkTS作为鸿蒙生态主推的应用开发语言,其声明式UI和高性能特性为构建复杂动效的界面提供了强大支持。在众多UI组件中,Canvas组件犹如一块画布,赋予开发者直接绘制图形的能力,是实现自定义、高灵活性UI动效的利器。

为了帮助大家将ArkTS和Canvas的理论知识转化为实战能力,我将带领你从零开始,一步步实现一个功能完备的"转盘抽奖"程序。这不仅仅是一个简单的绘图练习,更是一个综合性的练手项目。你可以通过本项目深入掌握:

  • Canvas的核心API:如何绘制扇形、文字、图像等基本元素。

  • ArkTS的响应式管理 :如何利用状态变量(@State)驱动UI动态更新。

  • 动画交互的实现:如何创建平滑的减速动画,模拟真实的转盘抽奖体验。

  • 业务逻辑的整合:如何处理抽奖规则、中奖判定与用户交互。

文章内容较长,建议您耐心阅读,相信通过本文的学习,你一定能掌握Canvas组件的使用方法。


目录

[1. 项目架构分析](#1. 项目架构分析)

[2. Canvas知识补充](#2. Canvas知识补充)

[3. 代码分析](#3. 代码分析)

[3.1 CheckEmptyUtils.ets文件(空值检查工具)](#3.1 CheckEmptyUtils.ets文件(空值检查工具))

[3.2 ColorConstants.ets文件(颜色常量)](#3.2 ColorConstants.ets文件(颜色常量))

[3.3 CommonConstants.ets文件(通用常量)](#3.3 CommonConstants.ets文件(通用常量))

[3.4 StyleConstants.ets文件(样式常量)](#3.4 StyleConstants.ets文件(样式常量))

[3.5. Logger.ets文件(日志工具)](#3.5. Logger.ets文件(日志工具))

[3.6 PrizeData.ets文件(奖品数据模型)](#3.6 PrizeData.ets文件(奖品数据模型))

[3.7 FillArcData.ets文件(弧形数据模型)](#3.7 FillArcData.ets文件(弧形数据模型))

[3.8 DrawModel.ets文件(绘制抽奖界面模型)](#3.8 DrawModel.ets文件(绘制抽奖界面模型))

[3.9 PrizeDialog.ets文件(中奖弹出框)](#3.9 PrizeDialog.ets文件(中奖弹出框))

[3.10 index.ets文件(主文件)](#3.10 index.ets文件(主文件))

[4. 项目演示和签名配置](#4. 项目演示和签名配置)

结语


⚠️ 注意: 文章所涉及的资源引用文件可在Gitte中查看在resources资源目录下,这样做的意义是提高可维护性,便于多语言 / 本地化支持,同时还可以降低**耦合度**

接下来教程开始


1. 项目架构分析

在正式开始之前,让我们先来了解一下项目的目录结构。

项目采用了清晰的模块化设计,将不同功能的代码分离到不同文件中:

复制代码
entry/src/main/ets/
├── pages/
│   ├── Index.ets (主页面)
│   └── class/ (工具类和数据模型)
│       ├── StyleConstants.ets (样式常量)
│       ├── CommonConstants.ets (通用常量)
│       ├── ColorConstants.ets (颜色常量)
│       ├── DrawModel.ets (绘制模型)
│       ├── PrizeData.ets (奖品数据模型)
│       ├── FillArcData.ets (弧形数据模型)
│       ├── PrizeDialog.ets (中奖对话框)
│       ├── Logger.ets (日志工具)
│       └── CheckEmptyUtils.ets (空值检查工具)

这种结构化的组织方式使得代码易于维护和扩展。

2. Canvas知识补充

简单来说,Canvas 组件就是 HarmonyOS 给咱们提供的一块 "画布",咱们可以在上面自由绘制各种图形、文字,甚至进行 AI 图像分析。它从 API version 8 开始支持,后续版本还不断增加了新功能,像 API 12 + 就加入了 AI 分析相关的能力。

具体的使用方法可以参考: Canvas开发指导,我在这就不再赘述了。(一定要特别注意:务必仔细阅读文档,Canvas 的核心属性与方法对于理解相关代码至关重要。)

总之,画布Canvas是一个非常复杂的课题,我们这里只针对其用法做探讨。

3. 代码分析

3.1 CheckEmptyUtils.ets文件(空值检查工具)

javascript 复制代码
class CheckEmptyUtils {
  isEmptyObj(obj: object | string) {
    return (typeof obj === 'undefined' || obj === null || obj === '');
  }
  isEmptyStr(str: string) {
    return str.trim().length === 0;
  }
  isEmptyArr(arr: Array<string>) {
    return arr.length === 0;
  }
}

export default new CheckEmptyUtils();

这段代码定义了一个名为CheckEmptyUtils的类,它提供了三个方法来检查不同类型的值是否为空。
isEmptyObj 方法用来检查对象或字符串是否为"空"值,检查条件包括undefined(未定义)null(空值)空字符串(' '),返回值为布尔类型。

isEmptyStr 方法用来检查字符串是否为空(包含空白字符的情况),使用trim() 去除首尾空白字符,如果去除后的长度为0,返回true

isEmptyArr方法用来检查字符串数组是否为空,如果数组长度为0,返回true。

最后导出这个类实例,这样在别处使用时不需要再new,直接导入使用即可

3.2 ColorConstants.ets文件(颜色常量)

TypeScript 复制代码
export default class ColorConstants {
  static readonly FLOWER_OUT_COLOR: string = '#ED6E21';
  static readonly FLOWER_INNER_COLOR: string = '#F8A01E';
  static readonly OUT_CIRCLE_COLOR: string = '#F7CD03';
  static readonly WHITE_COLOR: string = '#FFFFFF';
  static readonly INNER_CIRCLE_COLOR: string = '#F8A01E';
  static readonly ARC_PINK_COLOR: string = '#FFC6BD';
  static readonly ARC_YELLOW_COLOR: string = '#FFEC90';
  static readonly ARC_GREEN_COLOR: string = '#ECF9C7'
  static readonly TEXT_COLOR: string = '#ED6E21';
}

这段代码定义了项目中使用的颜色常量类,用于集中管理项目中使用的颜色值。

常量名 颜色值 颜色描述 用途推测
FLOWER_OUT_COLOR #ED6E21 橙红色 花朵外部颜色
FLOWER_INNER_COLOR #F8A01E 橙黄色 花朵内部颜色
OUT_CIRCLE_COLOR #F7CD03 亮黄色 外部圆圈颜色
WHITE_COLOR #FFFFFF 纯白色 通用白色
INNER_CIRCLE_COLOR #F8A01E 橙黄色 内部圆圈颜色
ARC_PINK_COLOR #FFC6BD 淡粉色 弧形粉色
ARC_YELLOW_COLOR #FFEC90 浅黄色 弧形黄色
ARC_GREEN_COLOR #ECF9C7 淡绿色 弧形绿色
TEXT_COLOR #ED6E21 橙红色 文字颜色

static readonly表示静态只读,每个属性都使用这个修饰符声明,确保它们属于类本身而非实例,且值不可修改。提高代码的可读性和维护性。

使用export default默认导出ColorConstants类,供其他模块使用。

可将此文件内容直接粘贴至您的项目中使用,无需你重新定义颜色常量。

3.3 CommonConstants.ets文件(通用常量)

TypeScript 复制代码
export default class CommonConstants {
   // 图片资源常量
  static readonly WATERMELON_IMAGE_URL: string = 'resources/base/media/ic_watermelon.png';
  static readonly HAMBURG_IMAGE_URL: string = 'resources/base/media/ic_hamburg.png';
  static readonly CAKE_IMAGE_URL: string = 'resources/base/media/ic_cake.png';
  static readonly BEER_IMAGE_URL: string = 'resources/base/media/ic_beer.png';
  static readonly SMILE_IMAGE_URL: string = 'resources/base/media/ic_smile.png';
   // 几何角度常量
  static readonly TRANSFORM_ANGLE: number = -120;
  static readonly CIRCLE: number = 360;
  static readonly HALF_CIRCLE: number = 180;
   // 数量尺寸常量
  static readonly COUNT: number = 6;
  static readonly SMALL_CIRCLE_COUNT: number = 8;
  static readonly IMAGE_SIZE: number = 40;
  static readonly ANGLE: number = 270;
  static readonly DURATION: number = 4000;
   // 基础数字常量
  static readonly ONE: number = 1;
  static readonly TWO: number = 2;
  static readonly THREE: number = 3;
  static readonly FOUR: number = 4;
  static readonly FIVE: number = 5;
  static readonly SIX: number = 6;
   // 花瓣相关比例
  static readonly FLOWER_POINT_Y_RATIOS: number = 0.255;
  static readonly FLOWER_RADIUS_RATIOS: number = 0.217;
  static readonly FLOWER_INNER_RATIOS: number = 0.193;
   // 圆圈相关比例
  static readonly OUT_CIRCLE_RATIOS: number = 0.4;
  static readonly SMALL_CIRCLE_RATIOS: number = 0.378;
  static readonly SMALL_CIRCLE_RADIUS: number = 4.1;
  static readonly INNER_CIRCLE_RATIOS: number = 0.356;
  static readonly INNER_WHITE_CIRCLE_RATIOS: number = 0.339;
  static readonly INNER_ARC_RATIOS: number = 0.336;
   // 图片位置比例
  static readonly IMAGE_DX_RATIOS: number = 0.114;
  static readonly IMAGE_DY_RATIOS: number = 0.052;
   // Canvas绘制参数
  static readonly ARC_START_ANGLE: number = 34;
  static readonly ARC_END_ANGLE: number = 26;
  static readonly TEXT_ALIGN: CanvasTextAlign = 'center';
  static readonly TEXT_BASE_LINE: CanvasTextBaseline = 'middle';
  static readonly CANVAS_FONT: string = 'px sans-serif';
}

   // 枚举定义(奖品分区)
export enum EnumeratedValue {
  ONE = 1,
  TWO = 2,
  THREE = 3,
  FOUR = 4,
  FIVE = 5,
  SIX = 6
}

这段代码定义了一个通用常量类和一个枚举,主要用于管理转盘抽奖类应用的各种参数。

默认导出 CommonConstants 类,命名导出 EnumeratedValue 枚举,供其他模块使用。

​​​​1. 图片资源常量

常量名 类型 描述
WATERMELON_IMAGE_URL 'resources/base/media/ic_watermelon.png' string 西瓜奖品图片路径
HAMBURG_IMAGE_URL 'resources/base/media/ic_hamburg.png' string 鼠标奖品图片路径
CAKE_IMAGE_URL 'resources/base/media/ic_cake.png' string 蛋糕奖品图片路径
BEER_IMAGE_URL 'resources/base/media/ic_beer.png' string U盘奖品图片路径
SMILE_IMAGE_URL 'resources/base/media/ic_smile.png' string 空奖图片路径

2. 几何角度常量

常量名 类型 描述
TRANSFORM_ANGLE -120 number 起始变换角度
CIRCLE 360 number 完整圆角度
HALF_CIRCLE 180 number 半圆角度
ANGLE 270 number 特定角度
ARC_START_ANGLE 34 number 弧形起始角度
ARC_END_ANGLE 26 number 弧形结束角度

3. 数量与尺寸常量

常量名 类型 描述
COUNT 6 number 奖品分区数量
SMALL_CIRCLE_COUNT 8 number 装饰小圆点数量
IMAGE_SIZE 40 number 奖品图片尺寸(像素)
DURATION 4000 number 动画持续时间(毫秒)
SMALL_CIRCLE_RADIUS 4.1 number 小圆点半径(像素)

4. 基础数字常量

常量名 类型 描述
ONE 1 number 数字1
TWO 2 number 数字2
THREE 3 number 数字3
FOUR 4 number 数字4
FIVE 5 number 数字5
SIX 6 number 数字6

5. 比例参数常量(相对于画布尺寸的比例)

5.1 花瓣相关比例

常量名 类型 描述
FLOWER_POINT_Y_RATIOS 0.255 number 花瓣Y坐标比例
FLOWER_RADIUS_RATIOS 0.217 number 花瓣半径比例
FLOWER_INNER_RATIOS 0.193 number 花瓣内圆比例

5.2 圆圈相关比例

常量名 类型 描述
OUT_CIRCLE_RATIOS 0.4 number 外圆比例
SMALL_CIRCLE_RATIOS 0.378 number 小圆位置比例
INNER_CIRCLE_RATIOS 0.356 number 内圆比例
INNER_WHITE_CIRCLE_RATIOS 0.339 number 白色内圆比例
INNER_ARC_RATIOS 0.336 number 内弧比例

5.3 图片位置比例

常量名 类型 描述
IMAGE_DX_RATIOS 0.114 number 图片X偏移比例
IMAGE_DY_RATIOS 0.052 number 图片Y偏移比例

6. Canvas绘制参数

常量名 类型 描述
TEXT_ALIGN 'center' CanvasTextAlign 文本水平对齐方式
TEXT_BASE_LINE 'middle' CanvasTextBaseline 文本垂直对齐方式
CANVAS_FONT 'px sans-serif' string 字体样式(需拼接字号)

7. 枚举定义

枚举值 描述
EnumeratedValue.ONE 1 奖品编号1
EnumeratedValue.TWO 2 奖品编号2
EnumeratedValue.THREE 3 奖品编号3
EnumeratedValue.FOUR 4 奖品编号4
EnumeratedValue.FIVE 5 奖品编号5
EnumeratedValue.SIX 6 奖品编号6

建议所有人(无论是初学者还是有开发经验的前辈)都直接使用这段代码,自行实现可能面临以下问题:

  1. 时间成本: 自己重新实现需要花费大量时间进行测量、调试和测试,而使用现有代码可以立即投入使用,节省开发时间。
  2. 可靠性:这段代码已经包含了经过测量的复杂角度计算和布局参数,避免了自行计算可能引入的错误。
  3. 维护性:代码结构清晰,常量分类明确,易于后续维护和修改。
  4. 一致性:使用统一的常量定义可以保证项目中的样式和行为一致。
  5. 性能:由于已经优化过,可能避免了不必要的计算和重复代码。

3.4 StyleConstants.ets文件(样式常量)

TypeScript 复制代码
export default class StyleConstants {
  static readonly FONT_WEIGHT: number = 500;
  static readonly FULL_PERCENT: string = '100%';
  static readonly BACKGROUND_IMAGE_SIZE: string = '38.7%';
  static readonly CENTER_IMAGE_WIDTH: string = '19.3%';
  static readonly CENTER_IMAGE_HEIGHT: string = '11.2%';
  static readonly ARC_TEXT_SIZE: number = fp2px(14);
}

这段代码定义了一个StyleConstants的类,它用于集中管理项目中的样式常量。

常量名称 类型 描述
FONT_WEIGHT number 500 字体权重值
FULL_PERCENT string '100%' 表示 100% 的百分比字符串
BACKGROUND_IMAGE_SIZE string '38.7%' 背景图片的尺寸比例
CENTER_IMAGE_WIDTH string '19.3%' 中心图片的宽度比例
CENTER_IMAGE_HEIGHT string '11.2%' 中心图片的高度比例
ARC_TEXT_SIZE number fp2px(14) 弧形文本的大小

使用export default默认导出StyleConstants类,供其他模块使用。

3.5. Logger.ets文件(日志工具)

在应用开发中,我们需要一些日志来调试、监控和错误追踪,所以需要定义一个日志工具便于排查bug。

TypeScript 复制代码
import hilog from '@ohos.hilog';

class Logger {
  private domain: number;
  private prefix: string;
  private format: string = '%{public}s, %{public}s';

  constructor(prefix: string = 'MyApp', domain: number = 0xFF00) {
    this.prefix = prefix;
    this.domain = domain;
  }

  debug(...args: string[]): void {
    hilog.debug(this.domain, this.prefix, this.format, args);
  }

  info(...args: string[]): void {
    hilog.info(this.domain, this.prefix, this.format, args);
  }

  warn(...args: string[]): void {
    hilog.warn(this.domain, this.prefix, this.format, args);
  }

  error(...args: string[]): void {
    hilog.error(this.domain, this.prefix, this.format, args);
  }
}

export default new Logger('[CanvasComponent]', 0xFF00)

首先导入hilog模块,这是HarmonyOS提供的系统日志模块。

接下来我们定义Logger类,包含:

  1. 日志领域标识(domain): 用于区分不同模块或应用的日志,0xFF00表示当前应用的日志领域,好处是可以按领域过滤和查看日志
  2. 日志前缀(prefix): 用于标识日志来源,便于搜索和过滤
  3. 日志格式(format): 用于定义日志消息的格式模板,其中**%{public}s** 表示公共字符串**%** ,**{private}s**表示私有字符串

然后定义一个类的构造函数,用于初始化对象的两个属性。

**日志级别:**可参考HiLog日志打印

这四个日志级别的参数类型是一样的:

参数名 类型 必填 说明
domain number 日志对应的领域标识,范围是0x0~0xFFFF,超出范围则日志无法打印。 建议开发者在应用内根据需要自定义划分。
tag string 指定日志标识,可以为任意字符串,建议用于标识调用所在的类或者业务行为。 tag最多为31字节,超出后会截断,不建议使用中文字符,可能出现乱码或者对齐问题。
format string 格式字符串,用于日志的格式化输出。格式字符串中可以设置多个参数,参数需要包含参数类型、隐私标识。 隐私标识分为{public}和{private},缺省为{private}。标识{public}的内容明文输出,标识{private}的内容以<private>过滤回显。
args any[] 与格式字符串format对应的可变长度参数列表。参数数目、参数类型必须与格式字符串中的标识一一对应。

3.6 PrizeData.ets文件(奖品数据模型)

我们需要存储中奖后的奖品信息,该怎么办呢?

定义一个奖品数据类来存储

TypeScript 复制代码
export default class PrizeData {
  message?: Resource;
  imageSrc?: string;
}

message 是奖品消息文本,imageSrc是奖品图片资源路径

使用export default默认导出ColorConstants类,方便其他模块使用。

3.7 FillArcData.ets文件(弧形数据模型)

既然是一个转盘抽奖程序,并且使用Canvas开发,那么就需要一个文件存储绘制弧形(圆形扇区)所需的各种参数。这样做的好处是通过数据封装让复杂的绘制逻辑变得更加清晰和可维护。(也就是面向对象设计)

绘制圆弧,我们需要以下几种参数:

  • x: 圆心x坐标
  • y: 圆心y坐标
  • radius: 圆半径,控制圆弧大小
  • startAngle: 起始角度
  • endAngle: 结束角度
TypeScript 复制代码
export default class FillArcData {
  x: number;
  y: number;
  radius: number;
  startAngle: number;
  endAngle: number;
  
  constructor(x: number, y: number, radius: number, startAngle: number, endAngle: number) {
    this.x = x;
    this.y = y;
    this.radius = radius;
    this.startAngle = startAngle;
    this.endAngle = endAngle;
  }
}

3.8 DrawModel.ets文件(绘制抽奖界面模型)

接下来是整个项目的关键步骤------开发Canvas转盘引擎。在这一步,我将引导大家完成从0到1的绘制。我们采用分层架构来实现这一目标。

我们需要引入必要的模块(大家不妨先想想需要用到哪些模块):

TypeScript 复制代码
// 常量管理层
import CommonConstants from './CommonConstants';        // 几何参数常量
import { EnumeratedValue } from './CommonConstants';    // 奖品编号
import ColorConstants from './ColorConstants';          // 颜色常量
import StyleConstants from './StyleConstants';          // 样式常量

// 数据模型层
import PrizeData from './PrizeData';                    // 奖品数据模型
import FillArcData from './FillArcData';                // 圆弧数据模型

// 工具层
import Logger from './Logger';                          // 日志工具
import CheckEmptyUtils from './CheckEmptyUtils';        // 空值检查工具

在开始绘制之前,我们首先定义一个绘图模型类 DrawModel,其中包含以下几个核心属性:

  • 起始角度 - 控制绘制的初始旋转位置

  • 平均角度 - 确定每个扇形区域的划分(360°/6=60°)

  • 屏幕宽度 - 作为响应式设计的基准尺寸

  • Canvas 绘制上下文 - 最重要的绘图执行接口,承载所有绘制操作

这些属性共同构成了转盘绘制的基础框架,为后续的分层绘制奠定基础。

TypeScript 复制代码
export default class DrawModel {
  private startAngle: number = 0;    
  private avgAngle: number = 60;     
  private screenWidth: number = 0;  
  private canvasContext?: CanvasRenderingContext2D; 
}

绘制入口方法draw

TypeScript 复制代码
draw(canvasContext: CanvasRenderingContext2D, screenWidth: number, screenHeight: number) {
  if (CheckEmptyUtils.isEmptyObj(canvasContext)) {
    Logger.error('[DrawModel][draw]画布上下文为空。');
    return;
  }

  // 初始化状态
  this.canvasContext = canvasContext;
  this.screenWidth = screenWidth;

  // 清空画布
  this.canvasContext.clearRect(0, 0, this.screenWidth, screenHeight);

  // 坐标居中(关键!)
  this.canvasContext.translate(this.screenWidth / 2, screenHeight / 2);

  // 分层绘制(从底层到顶层)
  this.drawFlower();        // 第1层: 花瓣背景
  this.drawOutCircle();     // 第2层: 外圆装饰  
  this.drawInnerCircle();   // 第3层: 内圆装饰
  this.drawInnerArc();      // 第4层: 奖品扇形
  this.drawArcText();       // 第5层: 弧形文字
  this.drawImage();         // 第6层: 奖品图片

  // 6. 恢复坐标系统
  this.canvasContext.translate(-this.screenWidth / 2, -screenHeight / 2);
}

在此步骤中,最关键的操作是坐标系统转换

转换前:坐标系原点位于画布左上角 (0, 0)

转换后:坐标系原点移动至画布中心点 (screenWidth/2, screenHeight/2)

这一变换是后续所有绘制操作的基础,它使得:

  • 所有几何计算可以围绕中心点进行,大幅简化复杂运算

  • 旋转动画能够自然围绕转盘中心展开

  • 响应式布局的实现更加直观和高效

接下来,我们将进入分层绘制 阶段。首先从最基础的通用弧形绘制引擎开始实现。

这个引擎将作为整个绘制系统的核心基础,负责所有圆弧、扇形和图形的绘制工作。它封装了底层的Canvas弧形绘制API,提供统一的参数校验和错误处理机制,确保后续所有图层绘制的稳定性和一致性。

通过先构建这个通用绘制引擎,我们可以为整个转盘的复杂图形绘制奠定坚实的技术基础。

TypeScript 复制代码
fillArc(fillArcData: FillArcData, fillColor: string) {
  if (CheckEmptyUtils.isEmptyObj(fillArcData) || CheckEmptyUtils.isEmptyStr(fillColor)) {
    Logger.error('[DrawModel][fillArc]参数为空');
    return;
  }

  // Canvas绘制流程
  if (this.canvasContext !== undefined) {
    this.canvasContext.beginPath();                    // 开始路径
    this.canvasContext.fillStyle = fillColor;          // 设置填充颜色
    this.canvasContext.arc(
      fillArcData.x, fillArcData.y,                    // 圆心坐标
      fillArcData.radius,                              // 半径
      fillArcData.startAngle, fillArcData.endAngle     // 起始和结束角度(弧度)
    );
    this.canvasContext.fill();                         // 填充路径
  }
}

第1层: 花瓣背景drawFlower()

TypeScript 复制代码
  drawFlower() {
    let beginAngle = this.startAngle + this.avgAngle;
    // 尺寸计算
    const pointY = this.screenWidth * CommonConstants.FLOWER_POINT_Y_RATIOS;  // 花瓣Y坐标
    const radius = this.screenWidth * CommonConstants.FLOWER_RADIUS_RATIOS;   // 外花瓣半径
    const innerRadius = this.screenWidth * CommonConstants.FLOWER_INNER_RATIOS;// 内花瓣半径
    // 绘制6个花瓣
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      this.canvasContext?.save();   // 保存当前画布状态

      // 将画布旋转到指定角度
      this.canvasContext?.rotate(beginAngle * Math.PI / CommonConstants.HALF_CIRCLE);

      // 绘制外花瓣
      this.fillArc(new FillArcData(0, -pointY, radius, 0, Math.PI * CommonConstants.TWO),
        ColorConstants.FLOWER_OUT_COLOR);

      // 绘制内花瓣
      this.fillArc(new FillArcData(0, -pointY, innerRadius, 0, Math.PI * CommonConstants.TWO),
        ColorConstants.FLOWER_INNER_COLOR);

      beginAngle += this.avgAngle;
      this.canvasContext?.restore();  // 恢复画布状态
    }
  }

花瓣位置计算:

  • 每个花瓣的圆心不在画布中心,而是在圆周上方(pointY)

  • 通过旋转画布,在6个对称位置绘制花瓣

  • 形成花朵状的背景效果

状态保存机制:
**save()restore()**确保每次旋转不影响其他绘制

第2层: 外圆装饰drawOutCircle()

TypeScript 复制代码
  drawOutCircle() {
  // 绘制外圆背景
    this.fillArc(new FillArcData(0, 0, this.screenWidth * CommonConstants.OUT_CIRCLE_RATIOS, 0,
      Math.PI * CommonConstants.TWO), ColorConstants.OUT_CIRCLE_COLOR);

    let beginAngle = this.startAngle;

    // 绘制装饰白点
    for (let i = 0; i < CommonConstants.SMALL_CIRCLE_COUNT; i++) {
      this.canvasContext?.save();
     
      // 旋转到当前角度
      this.canvasContext?.rotate(beginAngle * Math.PI / CommonConstants.HALF_CIRCLE);

      // 在圆周上绘制小白点
      this.fillArc(new FillArcData(this.screenWidth * CommonConstants.SMALL_CIRCLE_RATIOS, 0,
        CommonConstants.SMALL_CIRCLE_RADIUS, 0, Math.PI * CommonConstants.TWO),
        ColorConstants.WHITE_COLOR);

      beginAngle = beginAngle + CommonConstants.CIRCLE / CommonConstants.SMALL_CIRCLE_COUNT;
      this.canvasContext?.restore();
    }
  }

第3层: 内圆装饰drawInnerCircle()

TypeScript 复制代码
  drawInnerCircle() {
    // 绘制内圆
    this.fillArc(new FillArcData(0, 0, this.screenWidth * CommonConstants.INNER_CIRCLE_RATIOS, 0,
      Math.PI * CommonConstants.TWO), ColorConstants.INNER_CIRCLE_COLOR);

    // 绘制内圆
    this.fillArc(new FillArcData(0, 0, this.screenWidth * CommonConstants.INNER_WHITE_CIRCLE_RATIOS, 0,
      Math.PI * CommonConstants.TWO), ColorConstants.WHITE_COLOR);
  }

第4层: 奖品扇形区域drawInnerArc()

TypeScript 复制代码
  drawInnerArc() {
    // 6个扇形的颜色
    let colors = [
      ColorConstants.ARC_PINK_COLOR, ColorConstants.ARC_YELLOW_COLOR,
      ColorConstants.ARC_GREEN_COLOR, ColorConstants.ARC_PINK_COLOR,
      ColorConstants.ARC_YELLOW_COLOR, ColorConstants.ARC_GREEN_COLOR
    ];


    let radius = this.screenWidth * CommonConstants.INNER_ARC_RATIOS;  // 扇形半径

    // 绘制6个扇形
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      
      this.fillArc(new FillArcData(0, 0, radius, this.startAngle * Math.PI / CommonConstants.HALF_CIRCLE,  
        (this.startAngle + this.avgAngle) * Math.PI / CommonConstants.HALF_CIRCLE),colors[i]);

      //绘制扇形分割线
      this.canvasContext?.lineTo(0, 0);
      this.canvasContext?.fill();

      this.startAngle += this.avgAngle;
    }
  }

第5层: 弧形文字drawArcText()

TypeScript 复制代码
drawArcText() {
    if (this.canvasContext !== undefined) {
      // 设置文字样式
      this.canvasContext.textAlign = CommonConstants.TEXT_ALIGN; //水平居中
      this.canvasContext.textBaseline = CommonConstants.TEXT_BASE_LINE; // 垂直居中
      this.canvasContext.fillStyle = ColorConstants.TEXT_COLOR; // 文字颜色
      this.canvasContext.font = StyleConstants.ARC_TEXT_SIZE + CommonConstants.CANVAS_FONT; //字体样式
    }

    //奖品的文字资源
    let textArrays = [
      $r('app.string.text_smile'), // 空奖
      $r('app.string.text_hamburger'), //鼠标
      $r('app.string.text_cake'), // 蛋糕
      $r('app.string.text_smile'), // 空奖
      $r('app.string.text_beer'),  // U盘
      $r('app.string.text_watermelon') // 西瓜
    ];

    let arcTextStartAngle = CommonConstants.ARC_START_ANGLE; // 弧形文字开始角度
    let arcTextEndAngle = CommonConstants.ARC_END_ANGLE;     // 弧形文字结束角度

    // 在每个扇形内绘制弧形文字
    for (let i = 0; i < CommonConstants.COUNT; i++) {
    // 调用drawCircularText方法在指定角度范围内绘制文本
    // this.getResourceString: 获取资源字符串
    // (this.startAngle + arcTextStartAngle) * Math.PI / CommonConstants.HALF_CIRCLE: 计算起始角度(转换为弧度)
    // (this.startAngle + arcTextEndAngle) * Math.PI / CommonConstants.HALF_CIRCLE: 计算结束角度(转换为弧度)
      this.drawCircularText(this.getResourceString(textArrays[i]),
        (this.startAngle + arcTextStartAngle) * Math.PI / CommonConstants.HALF_CIRCLE,
        (this.startAngle + arcTextEndAngle) * Math.PI / CommonConstants.HALF_CIRCLE);
      this.startAngle += this.avgAngle;
    }
  }

 getResourceString(resource: Resource): string {
    if (CheckEmptyUtils.isEmptyObj(resource)) {
      Logger.error('[DrawModel][getResourceString]资源为空.')
      return '';
    }
    let resourceString: string = '';
    try {
      // 通过资源管理器同步获取资源ID对应的字符串
      resourceString = getContext(this).resourceManager.getStringSync(resource.id);
    } catch (error) {
      Logger.error(`[DrawModel][getResourceString]getStringSync failed, error : ${JSON.stringify(error)}.`);
    }
    return resourceString;
  }
弧形文字绘制算法drawCircularText()
TypeScript 复制代码
drawCircularText(textString: string, startAngle: number, endAngle: number) {
    // 检查传入的文本字符串是否为空
    if (CheckEmptyUtils.isEmptyStr(textString)) {
      Logger.error('[DrawModel][drawCircularText] textString is empty.');
      return;
    }

    // 定义CircleText类,用于描述圆形文本的位置和半径信息
    class CircleText {
      x: number = 0;          // 圆心x坐标
      y: number = 0;          // 圆心y坐标
      radius: number = 0;     // 圆半径
    }

    // 创建CircleText实例,设置圆心位置和半径
    let circleText: CircleText = {
      x: 0,                                    // 圆心x坐标设为0
      y: 0,                                    // 圆心y坐标设为0
      radius: this.screenWidth * CommonConstants.INNER_ARC_RATIOS  // 半径为屏幕宽度乘以内弧比例
    };
    
    // 计算文本绘制的实际半径(从内弧半径减去一部分)
    let radius = circleText.radius - circleText.radius / CommonConstants.COUNT;
    
    // 计算每个字符之间的角度差
    let angleDecrement = (startAngle - endAngle) / (textString.length - 1);
    
    // 初始化起始角度
    let angle = startAngle;
    
    // 初始化字符索引
    let index = 0;
    
    // 声明字符变量
    let character: string;

    // 遍历文本字符串中的每个字符
    while (index < textString.length) {
      // 获取当前索引位置的字符
      character = textString.charAt(index);
      
      // 保存当前画布状态
      this.canvasContext?.save();
      
      // 开始新的路径绘制
      this.canvasContext?.beginPath();
      
      // 将画布原点移动到计算出的字符位置
      this.canvasContext?.translate(circleText.x + Math.cos(angle) * radius,
        circleText.y - Math.sin(angle) * radius);
      
      // 旋转画布,使字符能够正确朝向
      this.canvasContext?.rotate(Math.PI / CommonConstants.TWO - angle);
      
      // 在当前位置绘制字符
      this.canvasContext?.fillText(character, 0, 0);
      
      // 更新角度,为下一个字符做准备
      angle -= angleDecrement;
      
      // 增加字符索引
      index++;
      
      // 恢复画布状态
      this.canvasContext?.restore();
    }
  }

这段代码的算法逻辑相对复杂,我特意添加了详细的注释说明,建议大家仔细思考其中的实现原理和数学关系。

第6层: 奖品图片drawImage()

TypeScript 复制代码
drawImage() {
    // 初始化起始角度
    let beginAngle = this.startAngle;
    
    // 扇形区域的图片
    let imageSrc = [
      CommonConstants.WATERMELON_IMAGE_URL,   // 西瓜图片
      CommonConstants.BEER_IMAGE_URL,         // U盘图片
      CommonConstants.SMILE_IMAGE_URL,        // 空奖图片
      CommonConstants.CAKE_IMAGE_URL,         // 蛋糕图片
      CommonConstants.HAMBURG_IMAGE_URL,      // 鼠标图片
      CommonConstants.SMILE_IMAGE_URL         // 空奖图片
    ];
    
    // 遍历每个扇形区域,绘制对应的图片
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      // 创建图片对象
      let image = new ImageBitmap(imageSrc[i]);
      
      // 保存当前画布状态
      this.canvasContext?.save();
      
      // 旋转画布到指定角度
      this.canvasContext?.rotate(beginAngle * Math.PI / CommonConstants.HALF_CIRCLE);
      
      // 绘制图片到指定位置
      this.canvasContext?.drawImage(image, this.screenWidth * CommonConstants.IMAGE_DX_RATIOS,
        this.screenWidth * CommonConstants.IMAGE_DY_RATIOS, CommonConstants.IMAGE_SIZE,
        CommonConstants.IMAGE_SIZE);
      
      beginAngle += this.avgAngle;
      
      // 恢复画布状态
      this.canvasContext?.restore();
    }
  }

中奖判定算法showPrizeData()

TypeScript 复制代码
  showPrizeData(randomAngle: number): PrizeData {
    // 遍历所有扇形区域(奖品区域)
    for (let i = 1; i <= CommonConstants.COUNT; i++) {
      // 判断随机角度是否落在当前扇形区域内
      // 每个扇形区域的角度范围是 (i-1) * this.avgAngle 到 i * this.avgAngle
      if (randomAngle <= i * this.avgAngle) {
        // 如果落在当前区域内,返回该区域对应的奖品数据
        return this.getPrizeData(i);
      }
    }
    // 如果没有找到匹配的区域,返回空的奖品数据对象
    return new PrizeData();
  }

奖品数据映射getPrizeData()

TypeScript 复制代码
 getPrizeData(scopeNum: number): PrizeData {
    let prizeData: PrizeData = new PrizeData();

    // 根据不同的结果映射不同数据
    switch (scopeNum) {
      case EnumeratedValue.ONE:
        prizeData.message = $r('app.string.prize_text_watermelon');
        prizeData.imageSrc = CommonConstants.WATERMELON_IMAGE_URL;
        break;

      case EnumeratedValue.TWO:
        prizeData.message = $r('app.string.prize_text_beer');
        prizeData.imageSrc = CommonConstants.BEER_IMAGE_URL;
        break;

      case EnumeratedValue.THREE:
        prizeData.message = $r('app.string.prize_text_smile');
        prizeData.imageSrc = CommonConstants.SMILE_IMAGE_URL;
        break;

      case EnumeratedValue.FOUR:
        prizeData.message = $r('app.string.prize_text_cake');
        prizeData.imageSrc = CommonConstants.CAKE_IMAGE_URL;
        break;

      case EnumeratedValue.FIVE:
        prizeData.message = $r('app.string.prize_text_hamburger');
        prizeData.imageSrc = CommonConstants.HAMBURG_IMAGE_URL;
        break;

      case EnumeratedValue.SIX:
        prizeData.message = $r('app.string.prize_text_smile');
        prizeData.imageSrc = CommonConstants.SMILE_IMAGE_URL;
        break;

      default:
        break;
    }
    return prizeData;
  }

3.9 PrizeDialog.ets文件(中奖弹出框)

图片来源于网络,仅作为教学使用,如涉及侵权,请联系我删除

从图中可见,当转盘抽中奖品时会弹出"中奖了"提示窗。因此需设计一个展示中奖结果的弹窗,其中应包含奖品图片及其相关信息说明。

TypeScript 复制代码
import matrix4 from '@ohos.matrix4';
import PrizeData from './PrizeData';
import StyleConstants from './StyleConstants';
import CommonConstants from './CommonConstants';

@CustomDialog
export default struct PrizeDialog {
  @Link prizeData: PrizeData;
  @Link enableFlag: boolean;
  private controller?: CustomDialogController;

  build() {
    Column() {
      Image(this.prizeData.imageSrc !== undefined ? this.prizeData.imageSrc : '')
        .width($r('app.float.dialog_image_size'))
        .height($r('app.float.dialog_image_size'))
        .margin({
          top: $r('app.float.dialog_image_top'),
          bottom: $r('app.float.dialog_image_bottom')
        })
        .transform(matrix4.identity().rotate({
          x: 0,
          y: 0,
          z: 1,
          angle: CommonConstants.TRANSFORM_ANGLE
        }))

      Text(this.prizeData.message)
        .fontSize($r('app.float.dialog_font_size'))
        .textAlign(TextAlign.Center)
        .margin({ bottom: $r('app.float.dialog_message_bottom') })

      Text($r('app.string.text_confirm'))
        .fontColor($r('app.color.text_font_color'))
        .fontWeight(StyleConstants.FONT_WEIGHT)
        .fontSize($r('app.float.dialog_font_size'))
        .textAlign(TextAlign.Center)
        .onClick(() => {
          this.controller?.close();
          this.enableFlag = !this.enableFlag;
        })
    }
    .backgroundColor($r('app.color.dialog_background'))
    .width(StyleConstants.FULL_PERCENT)
    .height($r('app.float.dialog_height'))
  }
}

我来解释一下上述代码:

import 导入必要的库,其中 matrix4 是矩阵变换库(参考矩阵变换),作用是对奖品图片进行旋转动画效果,PrizeData 是奖品数据结构,StyleConstants 是样式配置,CommonConstants 是通用参数

使用**@CustomDialog** (参考基础自定义弹出框)装饰器自定义一个对话框组件PrizeDialog。用**@Link**双向绑定 prizeData(奖品数据) 和 enableFlag(用于控制对话框显示状态) 。

3.9.1 UI布局详解
  • 整体布局结构
TypeScript 复制代码
Column() {                    // 垂直排列的列布局
  Image(...)                  // 奖品图片
  Text(...)                   // 奖品描述
  Text(...)                   // 确认按钮
}
.backgroundColor($r('app.color.dialog_background'))  // 背景颜色
.width(StyleConstants.FULL_PERCENT)                  // 宽度
.height($r('app.float.dialog_height'))               // 高度
  • 奖品图片展示
TypeScript 复制代码
Image(this.prizeData.imageSrc !== undefined ? this.prizeData.imageSrc : '')
  .width($r('app.float.dialog_image_size'))     // 图片宽度
  .height($r('app.float.dialog_image_size'))    // 图片高度
  .margin({
    top: $r('app.float.dialog_image_top'),      // 上边距
    bottom: $r('app.float.dialog_image_bottom') // 下边距
  })
  .transform(matrix4.identity().rotate({        // 旋转变换
    x: 0, y: 0, z: 1,                          // 绕Z轴旋转
    angle: CommonConstants.TRANSFORM_ANGLE      // -120度
  }))
  • 奖品信息文字
TypeScript 复制代码
Text(this.prizeData.message)   // 显示奖品描述
  .fontSize($r('app.float.dialog_font_size'))  // 字体大小
  .textAlign(TextAlign.Center)                 // 居中对齐
  .margin({ bottom: $r('app.float.dialog_message_bottom') }) // 下边距
  • 确认按钮
TypeScript 复制代码
Text($r('app.string.text_confirm'))  // "确认"按钮文字
  .fontColor($r('app.color.text_font_color'))  // 文字颜色
  .fontWeight(StyleConstants.FONT_WEIGHT)      // 字体粗细(500)
  .fontSize($r('app.float.dialog_font_size'))  // 字体大小
  .textAlign(TextAlign.Center)                 // 居中对齐
  .onClick(() => {                             // 点击事件
    this.controller?.close();                  // 关闭对话框
    this.enableFlag = !this.enableFlag;        // 切换启用状态
  })

这个文件完整且优雅地实现了中奖对话框功能,在实际开发过程中,这样的代码是非常nice的

3.10 index.ets文件(主文件)

接下来,我们将在应用启动入口处集成上述组件,完成整个抽奖应用的搭建。

在这步我们需要使用@ohos.window (窗口)获取系统窗口

TypeScript 复制代码
import { window } from '@kit.ArkUI';
import Logger from './class/Logger';
import DrawModel from './class/DrawModel';
import PrizeDialog from './class/PrizeDialog';
import PrizeData from './class/PrizeData';
import StyleConstants from './class/StyleConstants';
import CommonConstants from './class/CommonConstants';

// 获取当前组件上下文
let context = getContext(this);

@Entry
@Component
struct Index {
  // Canvas渲染上下文设置
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  // Canvas绘制上下文
  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  // 屏幕宽度状态变量
  @State screenWidth: number = 0;
  // 屏幕高度状态变量
  @State screenHeight: number = 0;

  // 获取屏幕尺寸
  aboutToAppear() {
    window.getLastWindow(context)
      .then((windowClass: window.Window) => {
        let windowProperties = windowClass.getWindowProperties();
        this.screenWidth = px2vp(windowProperties.windowRect.width);
        this.screenHeight = px2vp(windowProperties.windowRect.height);
      })
      .catch((error: Error) => {
        Logger.error('无法获取窗口大小。原因:' + JSON.stringify(error));
      })
  }

  // 绘制模型实例
  @State drawModel: DrawModel = new DrawModel();
  // 旋转角度状态变量
  @State rotateDegree: number = 0;
  // 启用标志状态变量
  @State enableFlag: boolean = true;
  // 奖品数据状态变量
  @State prizeData: PrizeData = new PrizeData();

  // 自定义对话框控制器
  dialogController: CustomDialogController = new CustomDialogController({
    builder: PrizeDialog({
      prizeData: $prizeData,
      enableFlag: $enableFlag
    }),
    autoCancel: false
  });

  // 启动动画方法
  startAnimator() {
    // 生成随机角度
    let randomAngle = Math.round(Math.random() * CommonConstants.CIRCLE);
    // 获取中奖数据
    this.prizeData = this.drawModel.showPrizeData(randomAngle);

    // 执行旋转动画
    animateTo({
      duration: CommonConstants.DURATION,          // 动画持续时间
      curve: Curve.Ease,                           // 动画曲线
      delay: 0,                                    // 延迟时间
      iterations: 1,                               // 执行次数
      playMode: PlayMode.Normal,                   // 播放模式
      onFinish: () => {                            // 动画完成回调
        this.rotateDegree = CommonConstants.ANGLE - randomAngle;
        this.dialogController.open();              // 打开中奖对话框
      }
    }, () => {
      // 设置最终旋转角度
      this.rotateDegree = CommonConstants.CIRCLE * CommonConstants.FIVE +
      CommonConstants.ANGLE - randomAngle;
    })
  }

  // 构建UI界面
  build() {
    Stack({ alignContent: Alignment.Center }) {
      // Canvas画布组件
      Canvas(this.canvasContext)
        .width(StyleConstants.FULL_PERCENT)
        .height(StyleConstants.FULL_PERCENT)
          // Canvas准备就绪时开始绘制
        .onReady(() => {
          this.drawModel.draw(this.canvasContext, this.screenWidth, this.screenHeight);
        })
          // 旋转动画属性
        .rotate({
          x: 0,
          y: 0,
          z: 1,
          angle: this.rotateDegree,
          centerX: this.screenWidth / CommonConstants.TWO,
          centerY: this.screenHeight / CommonConstants.TWO
        })

      // 中心按钮图片
      Image($r('app.media.ic_center'))
        .width(StyleConstants.CENTER_IMAGE_WIDTH)
        .height(StyleConstants.CENTER_IMAGE_HEIGHT)
        .enabled(this.enableFlag)
        .margin({top:50})
          // 点击事件处理
        .onClick(() => {
          this.enableFlag = !this.enableFlag;
          this.startAnimator();  // 启动转盘动画
        })
    }
    // 设置Stack容器属性
    .width(StyleConstants.FULL_PERCENT)
    .height(StyleConstants.FULL_PERCENT)
    // 设置背景图片
    .backgroundImage($r('app.media.ic_background'), ImageRepeat.NoRepeat)
    .backgroundImageSize({
      width: StyleConstants.FULL_PERCENT,
      height: StyleConstants.BACKGROUND_IMAGE_SIZE
    })
  }
}

至此,我们已成功完成大转盘抽奖应用的全部开发工作!

如果学有余力的话可以优化:

  • 添加音效系统(旋转音效、中奖音效)

  • 增加奖品库存管理

  • 添加用户积分系统

4. 项目演示和签名配置

这是在本地模拟器上的演示效果:

使用本地模拟器需要配置签名文件:
步骤1

步骤2
如需登录,请登录
步骤3
配置好的签名信息在根目录的build-profile.json5下:
签名信息

若项目中遇到报错,或是对某些内容存在疑问,大家可以在评论区留言讨论、互相交流,也可以直接私信博主咨询~

作者有话说

最开始写这篇博客时,我动过用 AI 敷衍了事的念头,可尝试后发现 AI 无法识别 ets 文件,最后只能沉下心来,一点点手动梳理总结。

说实话,这个项目对新人开发者确实不太友好,难度远超预期 ------ 它不仅要求开发者熟练掌握 Canvas 的核心属性与方法,还需要通过精密的角度计算、复杂的坐标系变换来实现绘制效果。无论是扇形的分割逻辑、弧形文字的定位,还是旋转动画的实现,每一步都依赖严谨的极坐标转换与弧度运算,稍有偏差就会影响整体效果,开发过程中需要反复调试验证。

这篇文章最终写了 26000 字,也是我写博客以来,纯靠自己手动输出字数最多的一章(当然也有AI帮助润色优化)。不过有一点需要提前说明:文章没有特意照顾到编程小白,像一些基础语法细节,还需要大家自行探索补充。

写到这里,其实已经很疲惫很疲惫了,暂时就和大家分享这些吧。虽然整个过程充满挑战,但看到最终整理好的内容,还是觉得一切都值得。最后也祝愿所有鸿蒙(HarmonyOS)开发者,都能在技术路上稳步前行,越来越好。

相关推荐
水龙吟啸2 小时前
从零开始搭建深度学习大厦系列-4.Transformer生成式大语言模型
人工智能·深度学习·自然语言处理·大语言模型
KubeSphere 云原生2 小时前
云原生周刊:MetalBear 融资、Chaos Mesh 漏洞、Dapr 1.16 与 AI 平台新趋势
人工智能·云原生
HarmonyOS_SDK2 小时前
基于HarmonyOS SDK开放能力的微博社交体验构建实践
harmonyos
黎燃2 小时前
迈向星际智能:AI 自主决策如何重塑未来太空探索
人工智能
yenggd2 小时前
QoS配置案例
网络·华为
狗头大军之江苏分军2 小时前
当AI小智遇上股票:一个不写死代码的智能股票分析工具诞生记
前端·人工智能·python
xuanwuziyou2 小时前
Dify本地化部署和应用
人工智能
说私域3 小时前
定制开发开源AI智能名片S2B2C商城小程序的MarTech Landscape构成与分析
人工智能·小程序·开源
CoovallyAIHub3 小时前
时隔 8 年,李飞飞领衔,CS231n 2025版来了!
深度学习·算法·计算机视觉