鸿蒙原生 ArkTS 开发实战:构建「一键生成画图 NEXT」—— 从零到一打造智能绘画应用
鸿蒙原生 ArkTS 开发实战:构建「一键生成画图 NEXT」—— 从零到一打造智能绘画应用


一、引言
1.1 关于 HarmonyOS NEXT
HarmonyOS NEXT(鸿蒙星河版)是华为完全剥离 Android 代码、基于 OpenHarmony 演进的全场景智能操作系统。它采用自研的 ArkTS 语言作为主力应用开发语言,配合 ArkUI 声明式 UI 框架,为开发者提供了一套高效、简洁、跨设备的应用构建方案。本文基于 HarmonyOS NEXT 6.1.1(API 24)SDK,完整记录一款名为「一键生成画图 NEXT」的智能绘画应用从设计到实现的全过程。
1.2 为什么选择 ArkTS 开发绘图应用
ArkTS(Ark TypeScript)是 TypeScript 的超集,它在保留 TypeScript 语法灵活性的基础上,强化了静态类型检查,并引入了 ArkUI 的声明式 UI 描述能力。对于 Canvas 绘图这类重度 UI 交互场景,ArkTS 的优势十分突出:
- 声明式 UI + 响应式状态:通过
@State装饰器实现状态驱动 UI 刷新,无需手动操作 DOM - Canvas 2D API 完备:完整支持 CanvasRenderingContext2D 标准接口
- 触摸事件天然支持:onTouch 回调链 Down→Move→Up 与绘图操作高度匹配
- 动画能力丰富:animateTo、setTimeout 等机制可灵活驱动逐帧动画
1.3 应用功能纵览
「一键生成画图 NEXT」是一款面向鸿蒙设备的智能绘画应用,核心功能分为三层:
基础绘制层
- 自由画布:手指/鼠标拖拽实时绘制
- 五种笔刷:铅笔、喷枪、马克笔、荧光笔、橡皮擦
- 16 种预设颜色 + 5 级笔刷粗细 + 6 种画布背景
智能生成层
- 五种一键生成图案:花朵、螺旋、万花筒、曼陀罗、星爆
- 逐帧动画展示生成过程
- 实时生成进度展示
工具辅助层
- 撤销操作(最多 20 步历史记录)
- 左右镜像对称绘制
- 参考网格辅助线
- 画布作品保存到设备相册
二、开发环境与项目初始化
2.1 环境要求
| 项目 | 版本 |
|---|---|
| 操作系统 | Windows 10/11 或 macOS |
| DevEco Studio | 5.0+ |
| HarmonyOS SDK | API 24 (6.1.1) |
| Node.js | 18.x+ |
| hvigor | 内置(随 DevEco Studio 分发) |
2.2 创建项目
打开 DevEco Studio,选择 File → New → Create Project:
- Template: Empty Ability
- Project Name: MyApplication5
- Bundle Name: com.example.myapplication
- Compatible SDK: 6.1.1(24)
- Device: Phone
创建完成后,项目会自动生成标准目录结构:
MyApplication5/
├── AppScope/ # 应用全局配置
│ ├── app.json5 # 应用级元信息
│ └── resources/ # 全局资源
├── entry/ # 主模块
│ └── src/main/
│ ├── ets/ # ArkTS 源码
│ │ ├── entryability/ # Ability 生命周期
│ │ └── pages/ # 页面组件
│ ├── module.json5 # 模块配置
│ └── resources/ # 模块级资源
├── build-profile.json5 # 构建配置
└── hvigor/ # 构建工具配置
2.3 构建配置文件
在 build-profile.json5 中,SDK 版本配置如下:
{
"app": {
"products": [
{
"name": "default",
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}
]
}
}
该配置确保应用运行在 API 24 及以上版本,同时向下兼容到 API 24。
三、ArkTS 核心概念速览
在进入代码之前,先梳理 ArkTS 中本应用使用到的核心概念:
3.1 @Entry 与 @Component
@Entry 标记页面入口,@Component 声明这是一个可复用的组件:
@Entry
@Component
struct MyComponent {
build() {
// 声明式 UI
}
}
3.2 @State 响应式状态
@State 是 ArkTS 中最核心的装饰器——被它修饰的变量一旦变化,所有依赖它的 UI 会自动重新渲染:
@State currentColor: string = '#FF4757';
@State brushSize: number = 4;
当 this.currentColor = '#1E90FF' 执行时,所有绑定 this.currentColor 的 UI 元素自动更新。
3.3 声明式 UI 构建
build() 方法内通过链式调用描述 UI:
build() {
Column({ space: 10 }) {
Text('Hello')
.fontSize(20)
.fontColor('#FF0000')
.onClick(() => { /* 事件 */ });
}
.width('100%')
.height('100%');
}
3.4 Canvas 画布
通过 Canvas 组件 + CanvasRenderingContext2D 进行 2D 绘图:
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();
build() {
Canvas(this.canvasCtx)
.width('100%')
.height(300)
.onReady(() => { /* 初始化 */ })
.onTouch((event) => { /* 触摸处理 */ });
}
四、首页设计:动态欢迎页
4.1 设计目标
首页作为应用的第一印象,需要传达品牌的视觉调性。设计目标包括:
- 深紫色渐变背景,营造创意氛围
- 温和的入场动画(缩放 + 淡入)
- 四个功能亮点图标展示
- 突出的「开始创作」按钮,带动画反馈
- 按钮点击后路由跳转到主画布页
4.2 完整代码实现
文件:entry/src/main/ets/pages/Index.ets
import { router } from '@kit.ArkUI';
@Entry
@Component
struct Index {
@State scaleAnim: number = 0.8;
@State opacityAnim: number = 0;
@State buttonScale: number = 1;
aboutToAppear(): void {
animateTo({ duration: 800, curve: Curve.Friction }, () => {
this.scaleAnim = 1;
this.opacityAnim = 1;
});
}
build() {
Stack() {
// 渐变背景
Column()
.width('100%').height('100%')
.linearGradient({
colors: [
['#0C0C1E', 0],
['#1A1A3E', 0.4],
['#2D1B69', 0.7],
['#6C5CE7', 1]
]
});
// 装饰粒子
Column() {
Circle().width(120).height(120)
.fill('#FFFFFF').opacity(0.03)
.position({ x: -30, y: -30 });
Circle().width(80).height(80)
.fill('#A55EEA').opacity(0.08)
.position({ x: 260, y: 40 });
Circle().width(150).height(150)
.fill('#FF6B81').opacity(0.05)
.position({ x: 200, y: 400 });
Circle().width(60).height(60)
.fill('#2ED573').opacity(0.06)
.position({ x: 20, y: 450 });
}
.width('100%').height('100%');
// 内容区域
Column({ space: 0 }) {
Blank().layoutWeight(1);
// 应用图标
Stack() {
Circle().width(90).height(90)
.fill('rgba(255,255,255,0.12)');
Text('🎨').fontSize(44);
}.margin({ bottom: 20 });
// 标题(带动画)
Text('一键生成画图')
.fontSize(36).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF').textAlign(TextAlign.Center)
.scale({ x: this.scaleAnim, y: this.scaleAnim })
.opacity(this.opacityAnim);
// 副标题
Text('NEXT · 新一代智能绘画工具')
.fontSize(15).fontColor('rgba(255,255,255,0.6)')
.letterSpacing(4).margin({ top: 10, bottom: 8 });
Blank().layoutWeight(1);
// 功能亮点
Row({ space: 20 }) {
FeatureIcon('🎯', '自由绘制');
FeatureIcon('✨', '一键生成');
FeatureIcon('🌈', '绚丽色彩');
FeatureIcon('💾', '保存分享');
}
.width('100%').justifyContent(FlexAlign.Center)
.margin({ bottom: 40 });
// 开始创作按钮
Button() {
Row({ space: 8 }) {
Text('✏️').fontSize(20);
Text('开始创作').fontSize(18)
.fontColor('#FFFFFF');
}
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
}
.width(220).height(54)
.backgroundColor('#6C5CE7').borderRadius(27)
.shadow({
radius: 20,
color: 'rgba(108, 92, 231, 0.5)',
offsetX: 0, offsetY: 8
})
.scale({ x: this.buttonScale, y: this.buttonScale })
.onClick(() => {
animateTo({ duration: 100, curve: Curve.Friction },
() => { this.buttonScale = 0.92; });
setTimeout(() => {
animateTo({ duration: 100, curve: Curve.Friction },
() => { this.buttonScale = 1; });
}, 100);
setTimeout(() => {
router.pushUrl({ url: 'pages/OneClickDraw' });
}, 250);
});
Blank().layoutWeight(1);
Text('v2.0 · 鸿蒙原生应用')
.fontSize(11).fontColor('rgba(255,255,255,0.25)')
.margin({ bottom: 30 });
}
.width('100%').height('100%')
.padding({ left: 40, right: 40 })
.alignItems(HorizontalAlign.Center);
}
.width('100%').height('100%')
}
}
4.3 技术要点解析
入场动画机制:
aboutToAppear() 是 ArkTS 组件的生命周期回调,在组件即将挂载时触发。我们利用 animateTo 函数驱动两个 @State 变量从初始值到目标值的变化,从而实现缩放和透明度的过渡动画:
aboutToAppear(): void {
animateTo({ duration: 800, curve: Curve.Friction }, () => {
this.scaleAnim = 1; // 0.8 → 1.0
this.opacityAnim = 1; // 0 → 1.0
});
}
Curve.Friction 曲线模拟了物理摩擦效果,让动画在接近终点时自然减速,给人舒适的手感。
按钮点击反馈:
点击按钮时,先用 100ms 缩放到 0.92(模拟按压),再用 100ms 恢复(模拟弹起),最后在 250ms 延迟后执行页面跳转。这种「三段式」交互反馈让操作显得更加自然和精致。
五、主画布页:核心架构
5.1 页面结构
主画布页 OneClickDraw.ets 是整个应用的核心,包含约 1140 行 ArkTS 代码。整体 UI 布局从上到下依次为:
┌─────────────────────────────────┐
│ 导航栏 (返回 | 标题 | 保存 | 撤销) │
├─────────────────────────────────┤
│ 生成模式选择器 (花朵/螺旋/...) │
├─────────────────────────────────┤
│ 颜色选择器 (16色可横向滚动) │
├─────────────────────────────────┤
│ 笔刷类型 + 对称/网格工具按钮 │
├─────────────────────────────────┤
│ 笔刷大小 | 背景色 | 清空按钮 │
├─────────────────────────────────┤
│ 生成进度条 (仅在生成中显示) │
├─────────────────────────────────┤
│ ┌───────────────────────────┐ │
│ │ Canvas 画布 │ │
│ │ (layoutWeight:1) │ │
│ └───────────────────────────┘ │
├─────────────────────────────────┤
│ ✨ 一键生成 (底部大按钮) │
└─────────────────────────────────┘
5.2 类型系统定义
代码开头的接口和枚举定义了整个应用的数据结构:
// ==================== 类型定义 ====================
interface ColorOption {
label: string;
color: string;
}
interface BrushOption {
label: string;
type: BrushType;
}
enum BrushType {
PENCIL = 'pencil',
SPRAY = 'spray',
MARKER = 'marker',
HIGHLIGHTER = 'highlighter',
ERASER = 'eraser'
}
enum GenMode {
FLOWER = 'flower',
SPIRAL = 'spiral',
KALEIDOSCOPE = 'kaleidoscope',
MANDALA = 'mandala',
STARBURST = 'starburst'
}
interface CanvasSnapshot {
data: ImageData;
}
设计思路:
ColorOption和BrushOption接口将 UI 展示数据与业务逻辑分离BrushType和GenMode枚举确保类型安全,避免魔法字符串CanvasSnapshot封装ImageData,为后续的撤销系统提供数据容器
5.3 组件状态管理
@Component
struct OneClickDraw {
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();
@State currentColor: string = '#FF4757';
@State brushSize: number = 4;
@State currentBrush: BrushType = BrushType.PENCIL;
@State isGenerating: boolean = false;
@State genProgress: number = 0;
@State bgColor: string = '#FFFFFF';
@State symMode: boolean = false;
@State showGrid: boolean = false;
private undoStack: CanvasSnapshot[] = [];
private readonly MAX_UNDO = 20;
private lastX: number = -1;
private lastY: number = -1;
private animationId: number = -1;
private currentGenMode: GenMode = GenMode.FLOWER;
// ...
}
状态分层设计:
| 类型 | 关键字 | 示例 | 用途 |
|---|---|---|---|
| 响应式 UI 状态 | @State |
currentColor, brushSize | 值变化 → UI 自动刷新 |
| 内部缓存 | private |
lastX, lastY | 无需触发 UI 更新的临时数据 |
| 持久化数据 | private readonly |
MAX_UNDO | 配置常量,永不改变 |
| Canvas 上下文 | private |
canvasCtx | 不参与 UI 渲染的外部对象 |
5.4 页面路由注册
要让 Index 和 OneClickDraw 两个页面之间能路由跳转,需要在 main_pages.json 中注册:
{
"src": [
"pages/Index",
"pages/OneClickDraw"
]
}
同时,module.json5 中引用 $profile:main_pages:
{
"module": {
"pages": "$profile:main_pages",
// ...
}
}
六、Canvas 画布与触摸事件
6.1 Canvas 组件布局
Canvas 组件占据工具栏下方、底部按钮上方的全部剩余空间:
Canvas(this.canvasCtx)
.width('100%')
.layoutWeight(1) // 占据 Column 剩余全部空间
.borderRadius(12)
.border({ width: 2, color: '#E0E0E0', style: BorderStyle.Solid })
.margin({ left: 8, right: 8, bottom: 4 })
.backgroundColor(this.bgColor)
.onReady(() => {
this.canvasWidth = this.canvasCtx.width;
this.canvasHeight = this.canvasCtx.height;
this.clearCanvas();
})
.onTouch((event: TouchEvent) => {
// 触摸事件处理
});
layoutWeight(1) 是 ArkUI 弹性布局的核心——它将父容器中所有子组件分配剩余空间后,按 layoutWeight 的比例分配剩余高度。此处设置为 1,意味着画布会吃掉 Column 中所有未被其他组件占用的垂直空间。
6.2 触摸事件的三阶段模型
鸿蒙的触摸事件通过 onTouch 回调传递,携带 TouchType 枚举标识当前阶段:
switch (event.type) {
case TouchType.Down:
// 手指按下:记录起点、画起始点、保存撤销快照
this.pushUndo();
this.lastX = x;
this.lastY = y;
this.drawDot(x, y);
break;
case TouchType.Move:
// 手指移动:画线(从上一个点连到当前点)
if (this.lastX >= 0 && this.lastY >= 0) {
this.handleBrushStroke(this.lastX, this.lastY, x, y);
}
this.lastX = x;
this.lastY = y;
break;
case TouchType.Up:
// 手指抬起:重置路径记录点
this.lastX = -1;
this.lastY = -1;
break;
}
触摸 → 绘制映射逻辑:
- Down:记录起始坐标,在起始位置画一个圆点(笔触开端),同时保存当前画布快照到撤销栈——每次新笔画开始时只保存一次,避免连续移动时大量 I/O
- Move:从
(lastX, lastY)到(x, y)画一条线段,然后更新last坐标为当前坐标,形成连续轨迹 - Up:将
lastX/lastY重置为 -1,结束本次笔画
6.3 对称模式
当用户开启对称模式(symMode: true)时,每次在点 (x, y) 绘制的同时,在镜像点 (canvasWidth - x, y) 绘制相同的笔触:
// Down 事件中的对称处理
if (this.symMode) {
const mirrorX = this.canvasWidth - x;
this.drawDot(mirrorX, y);
}
// Move 事件中的对称处理
if (this.symMode) {
const mirrorLastX = this.canvasWidth - this.lastX;
const mirrorX = this.canvasWidth - x;
this.handleBrushStroke(mirrorLastX, this.lastY, mirrorX, y);
}
这个功能的实现仅用了 4 行核心逻辑,却为用户创作对称图案提供了极大的便利。
七、五种画笔的算法实现
7.1 画笔路由
handleBrushStroke 方法作为画笔总调度,根据当前 currentBrush 类型分发到不同的渲染方法:
handleBrushStroke(x1: number, y1: number, x2: number, y2: number): void {
switch (this.currentBrush) {
case BrushType.PENCIL:
this.drawLine(x1, y1, x2, y2);
break;
case BrushType.SPRAY:
this.sprayDrawBetween(x1, y1, x2, y2);
break;
case BrushType.MARKER:
this.drawLine(x1, y1, x2, y2);
break;
case BrushType.HIGHLIGHTER:
this.highlighterStroke(x1, y1, x2, y2);
break;
case BrushType.ERASER:
this.eraserStroke(x1, y1, x2, y2);
break;
}
}
7.2 铅笔(Pencil)
铅笔是最基础的笔刷,直接使用 Canvas 的 lineTo 绘制线段:
drawLine(x1: number, y1: number, x2: number, y2: number): void {
const ctx = this.canvasCtx;
const lineWidth = this.currentBrush === BrushType.MARKER
? this.brushSize * 2 : this.brushSize;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = this.currentBrush === BrushType.ERASER
? this.bgColor : this.currentColor;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
关键参数:
lineCap: 'round':线段两端为圆形,让线条衔接更自然lineJoin: 'round':线段拐角为圆弧,避免锯齿感lineWidth = brushSize:粗细由用户选择的笔刷大小决定
7.3 喷枪(Spray)
喷枪模拟真实的喷漆效果,通过随机散布的点阵构成:
sprayDraw(x: number, y: number, intensity: number): void {
const ctx = this.canvasCtx;
const sprayRadius = this.brushSize * 3;
const dots = Math.floor(12 * intensity);
for (let i = 0; i < dots; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * sprayRadius;
const sx = x + dist * Math.cos(angle);
const sy = y + dist * Math.sin(angle);
ctx.beginPath();
ctx.arc(sx, sy, 1 + Math.random() * 1.5, 0, Math.PI * 2);
ctx.fillStyle = this.currentColor;
ctx.globalAlpha = 0.3 + Math.random() * 0.4;
ctx.fill();
}
ctx.globalAlpha = 1.0;
}
算法原理:
- 以触控点
(x, y)为圆心,brushSize × 3为半径 - 在圆内随机生成
12 × intensity个点 - 每个点的大小为
1 ~ 2.5px随机 - 每个点的透明度为
0.3 ~ 0.7随机 - 叠加后形成自然的喷溅纹理
两点之间移动时,通过线性插值确保密度均匀:
sprayDrawBetween(x1, y1, x2, y2): void {
const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const steps = Math.max(1, Math.floor(dist / 3));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = x1 + (x2 - x1) * t;
const y = y1 + (y2 - y1) * t;
this.sprayDraw(x, y, t === 0 || t === 1 ? 1 : 0.6);
}
}
7.4 马克笔(Marker)
马克笔与铅笔共用 drawLine 方法,区别在于线宽为 brushSize × 2,呈现更粗犷饱满的线条效果。
7.5 荧光笔(Highlighter)
荧光笔的特点是半透明、宽线条,模拟真实荧光笔的覆盖效果:
highlighterStroke(x1, y1, x2, y2): void {
const ctx = this.canvasCtx;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = this.currentColor;
ctx.lineWidth = this.brushSize * 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.3;
ctx.stroke();
ctx.globalAlpha = 1.0;
}
globalAlpha = 0.3 是核心——当线条与下方内容叠加时,会呈现半透明的覆盖效果,多个笔画交叉时色彩会更浓郁。
7.6 橡皮擦(Eraser)
橡皮擦使用背景色覆盖已有内容:
eraserStroke(x1, y1, x2, y2): void {
const ctx = this.canvasCtx;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = this.bgColor; // 使用当前背景色绘制
ctx.lineWidth = this.brushSize * 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
设计细节:当用户切换到橡皮擦后点击颜色选择器时会自动切回铅笔——因为橡皮擦状态下改变颜色无意义,此交互设计避免了用户困惑。
.onClick(() => {
this.currentColor = item.color;
if (this.currentBrush === BrushType.ERASER) {
this.currentBrush = BrushType.PENCIL; // 自动切回铅笔
}
});
八、一键生成:五种数学图案的动画算法
8.1 生成架构总览
所有生成模式遵循统一的生命周期模型:
startGenerating(): void {
if (this.isGenerating) return; // 防止重复触发
this.isGenerating = true; // 锁定 UI
this.genProgress = 0; // 重置进度
this.pushUndo(); // 保存当前画布到撤销栈
this.clearCanvas(); // 清空画布
switch (this.currentGenMode) {
case GenMode.FLOWER: this.genFlower(); break;
case GenMode.SPIRAL: this.genSpiral(); break;
case GenMode.KALEIDOSCOPE: this.genKaleidoscope(); break;
case GenMode.MANDALA: this.genMandala(); break;
case GenMode.STARBURST: this.genStarburst(); break;
}
}
动画驱动:由于 HarmonyOS ArkTS 不提供全局的 requestAnimationFrame,我们使用 setTimeout(fn, 33) 以约 30fps 的帧率驱动动画:
const animate = () => {
if (frame >= totalFrames) {
this.isGenerating = false;
this.genProgress = 100;
return;
}
drawFrame(); // 绘制当前帧
frame++;
this.genProgress = Math.round((frame / totalFrames) * 100);
this.animationId = setTimeout(animate, 33); // 请求下一帧
};
this.animationId = setTimeout(animate, 33); // 启动动画
8.2 花朵生成(Flower)
算法思路:
- 以画布中心为原点
- 绘制多层同心花瓣,每层的半径随进度递增
- 每层绕中心旋转不同角度,产生旋转绽放效果
- 使用二次贝塞尔曲线
quadraticCurveTo让花瓣弧度自然
genFlower(): void {
const ctx = this.canvasCtx;
const cx = this.canvasWidth / 2 || 180;
const cy = this.canvasHeight / 2 || 300;
const maxR = Math.min(cx, cy) * 0.8;
const palette = [
'#FF4757', '#FF6348', '#FFA502', '#FDCB6E',
'#2ED573', '#1E90FF', '#A55EEA', '#FF6B81',
'#6C5CE7', '#00CEC9', '#E84393', '#00B894'
];
const totalFrames = 70;
let frame = 0;
const petals = 10;
const animate = () => {
if (frame >= totalFrames) {
this.isGenerating = false;
this.genProgress = 100;
return;
}
const progress = frame / totalFrames;
const rotation = progress * Math.PI * 2;
// 画 10 层花瓣
for (let layer = 1; layer <= 10; layer++) {
const r = maxR * (layer / 10) * Math.min(progress * 1.2, 1);
const colorIdx = (layer + frame) % palette.length;
ctx.beginPath();
ctx.strokeStyle = palette[colorIdx];
ctx.lineWidth = 2.0;
ctx.globalAlpha = 0.5 + (layer / 10) * 0.3;
for (let i = 0; i < petals; i++) {
const angle = rotation * layer + (i / petals) * Math.PI * 2;
const endX = cx + r * Math.cos(angle);
const endY = cy + r * Math.sin(angle);
// 贝塞尔曲线控制点
const cpLen = r * 0.4;
const cpAngle = angle + Math.PI / (petals * 2);
const cpx = cx + cpLen * Math.cos(cpAngle);
const cpy = cy + cpLen * Math.sin(cpAngle);
ctx.moveTo(cx, cy);
ctx.quadraticCurveTo(cpx, cpy, endX, endY);
}
ctx.stroke();
}
// 中心花蕊(金色 + 粉色双层)
ctx.beginPath();
ctx.fillStyle = '#FDCB6E';
ctx.arc(cx, cy, 8 + progress * 6, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.fillStyle = '#FF6B81';
ctx.arc(cx, cy, 4 + progress * 3, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
frame++;
this.genProgress = Math.round((frame / totalFrames) * 100);
this.animationId = setTimeout(animate, 33);
};
this.animationId = setTimeout(animate, 33);
}
关键参数解释:
| 参数 | 值 | 作用 |
|---|---|---|
petals |
10 | 每层花瓣数量 |
layerCount |
10 | 同心层数,越多越丰富 |
totalFrames |
70 | 动画总帧数,约 2.3 秒 |
cpLen = r * 0.4 |
40% 半径 | 贝塞尔控制点距离,控制花瓣弯曲程度 |
progress * 1.2 |
略快于 1.0 | 让外层提前展开,产生层次感 |
8.3 螺旋生成(Spiral)
算法思路:
- 三条螺旋线从中心向外发散
- 每条螺旋线的角度从不同的起始偏移开始
- 半径随角度线性增长,形成阿基米德螺旋
genSpiral(): void {
const ctx = this.canvasCtx;
const cx = this.canvasWidth / 2 || 180;
const cy = this.canvasHeight / 2 || 300;
const maxR = Math.min(cx, cy) * 0.85;
const palette = [
'#1E90FF', '#00CEC9', '#2ED573', '#FDCB6E',
'#FF6348', '#FF4757', '#A55EEA', '#6C5CE7'
];
const totalFrames = 80;
let frame = 0;
const spirals = 3;
const totalRotations = 4;
const animate = () => {
if (frame >= totalFrames) { /* 结束 */ return; }
const progress = frame / totalFrames;
for (let s = 0; s < spirals; s++) {
const colorIdx = (s + frame) % palette.length;
ctx.beginPath();
ctx.strokeStyle = palette[colorIdx];
ctx.lineWidth = 2.5;
ctx.globalAlpha = 0.7;
const startAngle = (s / spirals) * Math.PI * 2;
const steps = Math.floor(60 * progress);
for (let i = 0; i <= steps; i++) {
const t = i / 60;
const angle = startAngle + t * totalRotations * Math.PI * 2;
const r = maxR * t * progress;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}
// ... 中心装饰 + 递归调用
this.animationId = setTimeout(animate, 33);
};
this.animationId = setTimeout(animate, 33);
}
螺旋的数学定义(阿基米德螺旋):
r = a × θ
其中 a 控制螺距,θ 是旋转角度。代码中 r = maxR * t * progress 实现了半径随角度线性增长,而 totalRotations = 4 表示每条螺旋旋转 4 整圈。
8.4 万花筒生成(Kaleidoscope)
算法思路:
- 将画布等分为 8 个扇形
- 每个扇区内绘制弧线和径向线
- 不同扇形使用不同颜色
- 整体缓慢旋转产生万花筒效果
genKaleidoscope(): void {
// ...
const segments = 8;
const segHalf = Math.PI / segments;
for (let seg = 0; seg < segments; seg++) {
const baseAngle = rotation + (seg / segments) * Math.PI * 2;
for (let layer = 1; layer <= 6; layer++) {
const r = maxR * (layer / 6) * Math.min(progress * 1.3, 1);
const a1 = baseAngle - segHalf * 0.7;
const a2 = baseAngle + segHalf * 0.7;
// 弧线
ctx.arc(cx, cy, r, a1, a2);
ctx.stroke();
// 从中心到弧两端的连线
ctx.moveTo(cx, cy);
ctx.lineTo(cx + r * Math.cos(a1), cy + r * Math.sin(a1));
ctx.stroke();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + r * Math.cos(a2), cy + r * Math.sin(a2));
ctx.stroke();
}
}
}
万花筒的视觉魅力来自旋转对称——8 个扇区的图案完全相同,但每个扇区在基础图案上叠加了不同的颜色和旋转角度,从而产生复杂而统一的视觉语言。
8.5 曼陀罗生成(Mandala)
算法思路:
- 曼陀罗是宗教艺术中的对称图案
- 从中心到外围绘制 8 层菱形/尖角环
- 每层有 16 个顶点,顶点交替位于内外半径
- 填充 + 描边组合,形成复杂的几何装饰
genMandala(): void {
// ...
const points = 16;
for (let ring = 1; ring <= 8; ring++) {
const r1 = maxR * ((ring - 1) / 8) * Math.min(progress * 1.2, 1);
const r2 = maxR * (ring / 8) * Math.min(progress * 1.2, 1);
for (let i = 0; i < points; i++) {
const angle = rotation + (i / points) * Math.PI * 2;
const nextAngle = rotation + ((i + 1) / points) * Math.PI * 2;
// 菱形单元:外顶点 → 内顶点 → 下一个外顶点
ctx.moveTo(cx + r2 * Math.cos(angle),
cy + r2 * Math.sin(angle));
ctx.lineTo(cx + r1 * Math.cos(angle + Math.PI / points),
cy + r1 * Math.sin(angle + Math.PI / points));
ctx.lineTo(cx + r2 * Math.cos(nextAngle),
cy + r2 * Math.sin(nextAngle));
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
}
菱形单元构造:每个菱形由三个顶点构成——两个在外环上相邻的顶点和一个在内环上的中点,这形成了曼陀罗标志性的锯齿/花瓣边缘。
8.6 星爆生成(Starburst)
算法思路:
- 绘制多层五角星,从内到外逐渐展开
- 每个五角星通过交替内外半径构造
- 叠加放射线(24 条)增强爆发感
genStarburst(): void {
// ...
const starPoints = 5;
for (let layer = 1; layer <= 7; layer++) {
const scale = (layer / 7) * Math.min(progress * 1.3, 1);
const r1 = maxR * scale * 0.5; // 内半径(星尖凹陷)
const r2 = maxR * scale; // 外半径(星尖凸出)
for (let i = 0; i < starPoints * 2; i++) {
const angle = rotation + (i / (starPoints * 2)) * Math.PI * 2;
const r = i % 2 === 0 ? r2 : r1; // 交替内外半径
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.fill();
}
// 放射线
const rayCount = 24;
for (let i = 0; i < rayCount; i++) {
const angle = rotation + (i / rayCount) * Math.PI * 2;
ctx.moveTo(cx, cy);
ctx.lineTo(cx + maxR * Math.min(progress * 1.2, 1) * Math.cos(angle),
cy + maxR * Math.min(progress * 1.2, 1) * Math.sin(angle));
ctx.stroke();
}
}
星形构造技巧:五角星有 10 个顶点(5 个外凸 + 5 个内凹),通过 i % 2 === 0 ? r2 : r1 在内外半径间交替,配合 0 到 2π 的 10 等分角度,绘制出标准的五角星路径。
九、撤销系统的实现
9.1 核心原理
撤销系统基于 Canvas 的 getImageData / putImageData API 实现:
- 保存快照:在每次开始新笔画时(
TouchType.Down),调用getImageData获取当前画布的像素数据,压入撤销栈 - 恢复快照:用户点击撤销时,从栈顶弹出最近一次快照,调用
putImageData恢复画布状态
interface CanvasSnapshot {
data: ImageData;
}
private undoStack: CanvasSnapshot[] = [];
private readonly MAX_UNDO = 20;
pushUndo(): void {
try {
const imageData = this.canvasCtx.getImageData(
0, 0, this.canvasWidth, this.canvasHeight
);
if (imageData) {
this.undoStack.push({ data: imageData });
// 限制最大栈深度,防止内存溢出
if (this.undoStack.length > this.MAX_UNDO) {
this.undoStack.shift();
}
}
} catch (e) {
// 画布尚未就绪时忽略
}
}
undo(): void {
if (this.undoStack.length === 0) return;
const snapshot = this.undoStack.pop();
if (snapshot) {
this.canvasCtx.putImageData(snapshot.data, 0, 0);
}
}
9.2 内存优化策略
ImageData 包含完整的像素缓冲区(RGBA × 宽 × 高),每次快照大约占用 width × height × 4 字节。以 360 × 640 的画布计算,一次快照约 900KB,20 次约 18MB。以下策略控制了内存增长:
- 仅在 Down 事件时保存:Move 和 Up 不触发保存,避免每帧都生成快照
- 固定最大栈深度:
MAX_UNDO = 20,超过时淘汰最旧的快照 - 清空画布时清空栈:
clearCanvas()中调用this.undoStack = []
9.3 触发时机
// 新笔画开始时保存
case TouchType.Down:
this.pushUndo(); // ← 保存撤销快照
// ...
// 一键生成前保存
startGenerating(): void {
// ...
this.pushUndo(); // ← 保存当前画布
this.clearCanvas();
// ...
}
// 切换背景色时保存
updateBg(): void {
this.pushUndo(); // ← 保存切换前的画布
// ...
}
十、画布保存到设备相册
10.1 实现流程
保存功能将画布内容导出为 JPEG 图片并写入设备媒体库,涉及三个 HarmonyOS 核心 Kit:
- Image Kit:从
ImageData创建PixelMap,再打包为 JPEG 二进制数据 - MediaLibrary Kit(PhotoAccessHelper):在媒体库中创建图片资产
- CoreFile Kit(fileIo):将二进制数据写入资产文件
saveCanvas(): void {
try {
const ctx = this.canvasCtx;
const width = this.canvasWidth || 1080;
const height = this.canvasHeight || 1920;
// 步骤1:获取画布像素数据
const imageData = ctx.getImageData(0, 0, width, height);
if (!imageData) {
console.warn('保存失败:无法获取画布数据');
return;
}
// 步骤2:创建 PixelMap
const buffer = imageData.data.buffer;
const imagePackerApi = image.createImagePacker();
const packOpts: image.PackingOption = {
format: 'image/jpeg',
quality: 95
};
const initialization: image.InitializationOptions = {
size: { width: width, height: height },
pixelFormat: image.PixelMapFormat.RGBA_8888,
alphaType: image.AlphaType.PREMUL
};
image.createPixelMap(buffer, initialization).then((pixelMap) => {
// 步骤3:打包为 JPEG
imagePackerApi.packing(pixelMap, packOpts).then((packedData) => {
// 步骤4:保存到媒体库
const context: common.Context = getContext(this);
const helper = photoAccessHelper.getPhotoAccessHelper(context);
helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg')
.then((assetUri: string) => {
const file: fileIo.File = fileIo.openSync(
assetUri, fileIo.OpenMode.READ_WRITE
);
fileIo.writeSync(file.fd, packedData);
fileIo.closeSync(file);
console.info('画布已保存');
})
.catch((createErr: Error) => {
console.error('创建文件失败: ' + JSON.stringify(createErr));
});
});
});
} catch (e) {
console.error('保存异常: ' + JSON.stringify(e));
}
}
10.2 权限配置
要将图片写入媒体库,需要在 module.json5 的 requestPermissions 字段中声明:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.WRITE_IMAGEVIDEO",
"reason": "$string:save_permission_reason"
}
]
}
}
同时需要在资源文件 string.json 中定义权限说明:
{
"string": [
{
"name": "save_permission_reason",
"value": "用于保存您创作的作品到设备相册"
}
]
}
10.3 数据类型转换链路
ImageData (像素缓冲区)
↓ image.createPixelMap(buffer, init)
PixelMap (图像对象)
↓ imagePackerApi.packing(pixelMap, opts)
ArrayBuffer (JPEG 二进制)
↓ fileIo.writeSync(fd, packedData)
设备存储 (JPEG 文件)
十一、辅助功能:网格与背景
11.1 网格绘制
网格线通过等距的水平和垂直线条实现。特殊之处在于:网格作为辅助参考线,需要画在现有内容之上,但切换开关时不能破坏已有内容。
drawGrid(): void {
const ctx = this.canvasCtx;
const gridSize = 30;
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
ctx.lineWidth = 1;
for (let x = 0; x < this.canvasWidth; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.canvasHeight);
ctx.stroke();
}
for (let y = 0; y < this.canvasHeight; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.canvasHeight, y);
ctx.stroke();
}
}
网格开关切换时,关闭网格需要「恢复」画布——实际上是用背景色覆盖网格,因为 Canvas 绘制不支持「撤销单次绘制操作」。如果需要保留已有内容,更完善的做法是将网格绘制在独立的 Canvas 层上,或用 globalCompositeOperation 实现。
11.2 六种背景色切换
private bgColors: ColorOption[] = [
{ label: '纯白', color: '#FFFFFF' },
{ label: '米黄', color: '#FFF8E7' },
{ label: '浅灰', color: '#F0F0F0' },
{ label: '淡蓝', color: '#E8F4FD' },
{ label: '淡粉', color: '#FDE8EF' },
{ label: '淡紫', color: '#F0E8FF' },
];
updateBg(): void {
this.pushUndo(); // 保存当前内容
const ctx = this.canvasCtx;
ctx.fillStyle = this.bgColor;
ctx.fillRect(0, 0, this.canvasWidth || 1080,
this.canvasHeight || 1920);
}
切换背景色时,会先保存撤销快照,再填充新背景。这意味着用户可以撤销背景切换操作,恢复到切换前的状态。
十二、构建与部署
12.1 构建命令
项目使用 hvigor 作为构建工具,执行以下命令完成编译打包:
hvigorw assembleHap --mode module -p product=default --no-daemon
--no-daemon 参数禁用守护进程,在 CI/CD 环境中可避免进程残留。构建完成后,HAP 包输出在 entry/build/default/outputs/ 目录下。
12.2 ArkTS 编译规则
在构建过程中,ArkTS 编译器会对代码进行严格检查。以下是本项目中遇到并解决的常见编译规则:
| 规则 ID | 规则描述 | 解决方案 |
|---|---|---|
arkts-no-any-unknown |
禁止使用 any/unknown 类型 |
为所有变量加上显式类型标注 |
arkts-no-types-in-catch |
catch 子句不支持类型标注 | 去除 catch 参数的类型声明 |
arkts-identifiers-as-prop-names |
标识符用作属性名需规范 | 确保属性和方法命名符合规范 |
12.3 构建配置
build-profile.json5 的关键配置:
{
"app": {
"products": [
{
"name": "default",
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
]
}
}
十三、总结与展望
13.1 技术总结
通过「一键生成画图 NEXT」的完整开发过程,我们覆盖了 HarmonyOS NEXT ArkTS 开发的以下核心技术点:
UI 框架层
- 声明式 UI 构建(Column、Row、Stack、Scroll 等布局组件)
- @State 响应式状态管理
- 组件生命周期(aboutToAppear)
- animateTo 过渡动画
- 页面路由(router.pushUrl / router.back)
Canvas 绘图层
- CanvasRenderingContext2D 核心 API(arc、lineTo、quadraticCurveTo、bezierCurveTo)
- 颜色与透明度控制(fillStyle、strokeStyle、globalAlpha)
- 线型控制(lineWidth、lineCap、lineJoin)
- 像素操作(getImageData、putImageData)
事件系统
- 触摸事件三阶段模型(Down → Move → Up)
- 多点触控适配(event.touches[0])
多媒体与文件
- Image Kit:PixelMap 创建与编码
- PhotoAccessHelper:媒体库资产创建
- fileIo:文件读写操作
算法与数学
- 三角函数的图形应用(cos/sin 定位圆周点)
- 贝塞尔曲线(quadraticCurveTo 绘制花瓣弧度)
- 阿基米德螺旋(半径随角度线性增长)
- 对称变换(镜像映射)
- 随机数的艺术化应用(喷枪散布)
13.2 可扩展方向
本应用已经具备完整的绘画工具链,但仍有很多可以扩展的方向:
功能扩展
- 图层系统:支持多层画布,独立编辑与混合模式
- 形状工具:矩形、圆形、多边形等几何形状绘制
- 文本工具:在画布上添加文字
- 滤镜效果:模糊、锐化、色彩调整等图像处理
- 导入图片:从相册选取图片作为画布或参考
性能优化
- Canvas 离屏渲染:使用离屏 Canvas 缓存复杂图案
- 增量渲染:只重绘变化的区域,减少 GPU 负载
- WebWorker:将计算密集型任务(如图案生成)移到后台线程
鸿蒙特性深化
- 分布式协同:手机和平板间协同绘画
- 手写笔适配:支持 Huawei M-Pencil 的压感和倾斜
- 元服务卡片:在桌面上展示最近作品
- 一次开发多端部署:适配折叠屏、平板、车机等形态
13.3 开发心得
在 HarmonyOS NEXT 上开发绘图应用的过程中,有几点深刻体会:
-
ArkTS 的开发体验:声明式 UI + 响应式状态的组合极大地减少了 UI 同步代码,让开发者可以更专注于业务逻辑。对于 Canvas 绘图这种命令式的操作,ArkTS 也提供了足够的灵活性。
-
严格类型检查是一把双刃剑:ArkTS 的编译时类型检查能避免大量运行时错误,但(如本文第 12 节所示)一些规则需要额外适配。理解了规则的初衷后,编码习惯会自然而然地改进。
-
Canvas API 的完备性:HarmonyOS 的 CanvasRenderingContext2D 实现了完整的 W3C 标准,Web 端的 Canvas 知识可以无缝迁移,降低了学习成本。
-
数学是创意的引擎:本应用中五个生成图案的核心驱动都是高中数学级别的三角函数和几何变换。这些简单的数学公式,在代码的编排下却能产生令人惊叹的视觉效果——这正是编程的魅力所在。
本文完整项目代码可在 DevEco Studio 中直接打开构建运行。SDK 版本:HarmonyOS NEXT 6.1.1 (API Level 24),开发语言:ArkTS,构建工具:hvigor。
更多推荐


所有评论(0)