当前位置: 首页 > news >正文

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

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

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

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

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

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

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

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

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


目录

1. 项目架构分析

2. Canvas知识补充

3. 代码分析

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

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

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

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

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

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

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

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

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

3.10 index.ets文件(主文件)

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文件(空值检查工具)

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文件(颜色常量)

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文件(通用常量)

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'stringU盘奖品图片路径
SMILE_IMAGE_URL'resources/base/media/ic_smile.png'string空奖图片路径

2. 几何角度常量

常量名类型描述
TRANSFORM_ANGLE-120number起始变换角度
CIRCLE360number完整圆角度
HALF_CIRCLE180number半圆角度
ANGLE270number特定角度
ARC_START_ANGLE34number弧形起始角度
ARC_END_ANGLE26number弧形结束角度

3. 数量与尺寸常量

常量名类型描述
COUNT6number奖品分区数量
SMALL_CIRCLE_COUNT8number装饰小圆点数量
IMAGE_SIZE40number奖品图片尺寸(像素)
DURATION4000number动画持续时间(毫秒)
SMALL_CIRCLE_RADIUS4.1number小圆点半径(像素)

4. 基础数字常量

常量名类型描述
ONE1number数字1
TWO2number数字2
THREE3number数字3
FOUR4number数字4
FIVE5number数字5
SIX6number数字6

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

5.1 花瓣相关比例

常量名类型描述
FLOWER_POINT_Y_RATIOS0.255number花瓣Y坐标比例
FLOWER_RADIUS_RATIOS0.217number花瓣半径比例
FLOWER_INNER_RATIOS0.193number花瓣内圆比例

5.2 圆圈相关比例

常量名类型描述
OUT_CIRCLE_RATIOS0.4number外圆比例
SMALL_CIRCLE_RATIOS0.378number小圆位置比例
INNER_CIRCLE_RATIOS0.356number内圆比例
INNER_WHITE_CIRCLE_RATIOS0.339number白色内圆比例
INNER_ARC_RATIOS0.336number内弧比例

5.3 图片位置比例

常量名类型描述
IMAGE_DX_RATIOS0.114number图片X偏移比例
IMAGE_DY_RATIOS0.052number图片Y偏移比例

6. Canvas绘制参数

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

7. 枚举定义

枚举值描述
EnumeratedValue.ONE1奖品编号1
EnumeratedValue.TWO2奖品编号2
EnumeratedValue.THREE3奖品编号3
EnumeratedValue.FOUR4奖品编号4
EnumeratedValue.FIVE5奖品编号5
EnumeratedValue.SIX6奖品编号6

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

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

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

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_WEIGHTnumber500字体权重值
FULL_PERCENTstring'100%'表示 100% 的百分比字符串
BACKGROUND_IMAGE_SIZEstring'38.7%'背景图片的尺寸比例
CENTER_IMAGE_WIDTHstring'19.3%'中心图片的宽度比例
CENTER_IMAGE_HEIGHTstring'11.2%'中心图片的高度比例
ARC_TEXT_SIZEnumberfp2px(14)弧形文本的大小

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

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

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

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日志打印

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

参数名类型必填说明
domainnumber

日志对应的领域标识,范围是0x0~0xFFFF,超出范围则日志无法打印。

建议开发者在应用内根据需要自定义划分。

tagstring指定日志标识,可以为任意字符串,建议用于标识调用所在的类或者业务行为。 tag最多为31字节,超出后会截断,不建议使用中文字符,可能出现乱码或者对齐问题。
formatstring

格式字符串,用于日志的格式化输出。格式字符串中可以设置多个参数,参数需要包含参数类型、隐私标识。

隐私标识分为{public}和{private},缺省为{private}。标识{public}的内容明文输出,标识{private}的内容以<private>过滤回显。

argsany[]与格式字符串format对应的可变长度参数列表。参数数目、参数类型必须与格式字符串中的标识一一对应。

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

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

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

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: 结束角度
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的绘制。我们采用分层架构来实现这一目标。

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

// 常量管理层
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 绘制上下文 - 最重要的绘图执行接口,承载所有绘制操作

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

export default class DrawModel {private startAngle: number = 0;    private avgAngle: number = 60;     private screenWidth: number = 0;  private canvasContext?: CanvasRenderingContext2D; 
}

绘制入口方法draw

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,提供统一的参数校验和错误处理机制,确保后续所有图层绘制的稳定性和一致性。

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

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()

  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()

  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()

  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()

  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()

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()
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坐标设为0y: 0,                                    // 圆心y坐标设为0radius: 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()

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()

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

奖品数据映射getPrizeData()

 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文件(中奖弹出框)

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

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

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布局详解
  • 整体布局结构
Column() {                    // 垂直排列的列布局Image(...)                  // 奖品图片Text(...)                   // 奖品描述Text(...)                   // 确认按钮
}
.backgroundColor($r('app.color.dialog_background'))  // 背景颜色
.width(StyleConstants.FULL_PERCENT)                  // 宽度
.height($r('app.float.dialog_height'))               // 高度
  • 奖品图片展示
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度}))

  • 奖品信息文字
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)      // 字体粗细(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 (窗口)获取系统窗口

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)开发者,都能在技术路上稳步前行,越来越好。

http://www.dtcms.com/a/398898.html

相关文章:

  • 力扣每日一刷Day 25
  • Windows安全机制--脚本执行防御
  • Chat2DB:零门槛数据库操作的无界解决方案
  • 即墨网站推广网络经营范围包括哪些
  • dify 源码分析 agent
  • 静态网站开发工具有哪些做网站用的文本编辑器
  • 搜索百科(4):OpenSearch — 开源搜索的新选择
  • 异常以及异常处理
  • 2025年国际知名品牌OMS订单管理系统选型指南:从产品架构,生态资源到成功项目交付案例解析|商派
  • 从传统CNN到ResNet:深度学习中的深层网络革命
  • RAG知识增强系统2 - 检索器retriever
  • 52Hz——FreeRTOS学习笔记——任务的创建
  • 百度权重排名高的网站如何用ps做网站效果图
  • 动态设计网站p2p理财网站开发要求
  • 【AI】【Java后端】RAG 实战示例:SpringBoot + 向量检索 + LLM 问答系统
  • Google Pixel 10 vs iPhone 17
  • 2种方式从springbean中获取bean实例
  • iPhone 无线充电发展历史
  • 做康复医院网站推广普通话手抄报
  • Win版 Visual Studio Code配置C++环境
  • 住房与住房建设部网站中美最新军事新闻最新消息
  • uniapp 项目打包时提示未添加videoplayer模块
  • 深入理解Roo Code中的Temperature参数
  • 四、PyTorch训练分类器教程:小张的CIFAR-10实战之旅
  • Unity-序列帧动画
  • 【每日一问】容性负载和感性负载有什么区别?
  • 做汽车保养的网站上企业信息的网站
  • 4-3〔O҉S҉C҉P҉ ◈ 研记〕❘ WEB应用攻击▸文件包含漏洞-A
  • 郑州网站建设国奥大厦南昌营销网站建设
  • 微服务项目->在线oj系统(Java-Spring)----7.0