N 边形对称万花筒:HarmonyOS API 24 Canvas 动画深度解析:有Gif效果图


用代码构建一个视觉上令人沉醉的 N 边形万花筒旋阵,在 HarmonyOS(API 24)上体验三角函数与 Canvas 渲染的完美融合。
一、引言
在移动端应用中,视觉效果往往是吸引用户的第一道门槛。一个精妙绝伦的动画,能够在瞬间传递出应用品质感与开发者对细节的追求。本文将以一个 N 边形对称万花筒 Canvas 动画 为案例,完整演示在 HarmonyOS NEXT(API 24)上,如何使用 @ComponentV2 组件化架构 + CanvasRenderingContext2D 2D 渲染上下文,构建一个可交互的、60fps 流畅运行的视觉盛宴。
这个应用我们将其命名为 “万花筒旋阵”(Kaleidoscope),它实现了以下核心效果:
- N 边形对称旋转:滑块调节 3~12 边,实时切换正三角形、四边形、五边形……十二边形
- 多层叠加深度感:2~8 层,每层半径递减、转速递增、透明度递减,形成无限延伸的视觉纵深
- 三角函数实时驱动:
cos/sin计算顶点坐标,setLineDash/lineDashOffset营造动态虚线环 - HSL 色调循环:色相 0~360° 可调,各层色相偏移 + 动画角度驱动色相漂移
- 粒子系统漫游:80 个粒子沿轨道运动 + 邻近粒子距离连线(类星云效果)
- 顶点光点呼吸:脉冲大小
3 + 2*sin(angle*2 + i)实现呼吸效果,径向渐变光晕渲染 - 内接星形弦图:N≥4 时自动绘制星形虚线连线,带动态偏移滚动
二、HarmonyOS API 24 特性概览
本项目的 build-profile.json5 中配置如下:
{
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)"
}
API 24(HarmonyOS NEXT)带来了若干重要更新,本项目充分利用了以下几项:
1. @ComponentV2 装饰器
API 24 推荐的组件定义方式。相比传统的 @Component,@ComponentV2 配合 @Local 装饰器,实现了更精细的响应式更新控制:
- @Local:仅装饰器所在组件会被标记为脏并重渲染,不会冒泡到父组件
- @Param:明确标记从父组件传入的参数,类型安全
- @Event:子组件向父组件发射事件的标准化方式
在我们的万花筒中,所有可调参数(sideCount、layerCount、rotationSpeed、hueBase)均使用 @Local 装饰,当用户拖动 Slider 时,只有这些参数变化触发 Canvas 重绘,而顶层 Index 组件不受影响。
2. CanvasRenderingContext2D 完整 2D 上下文
API 24 提供了完整的 Canvas 2D 上下文,支持:
- 路径操作:
beginPath、moveTo、lineTo、arc、closePath - 样式控制:
strokeStyle、fillStyle、shadowColor、shadowBlur - 渐变系统:
createRadialGradient径向渐变(多层光晕核心) - 虚线控制:
setLineDash、lineDashOffset(动态虚线环) - 变换矩阵:
translate、rotate、scale(预留扩展)
3. Toggle Switch 组件
API 24 的 Toggle 组件配合 ToggleType.Switch,提供了原生级的开关交互体验,支持自定义选中颜色和滑块颜色,完美适配深色主题。
4. Slider 滑块组件
滑块组件支持整数和浮点数步进,配合 trackThickness 自定义轨道粗细、selectedColor 自定义选中段颜色,与 HSL 动态色相互联,实现视觉统一。
三、完整代码清单
以下是 KaleidoscopePage.ets 的全部代码,共计 474 行,覆盖了从粒子数据结构、动画循环、核心绘制到 UI 交互面板的完整实现:
/**
* 万花筒旋阵 - N 边形对称 Canvas 动画
* ============================================
* 功能: N 边形对称万花筒,三角函数驱动动画,多层旋转叠加透明度
* 技术栈: @ComponentV2 + CanvasRenderingContext2D + setTimeout ~60fps
*
* Index.ets 挂载方式:
* import { KaleidoscopePage } from './KaleidoscopePage';
* Stack() { KaleidoscopePage() }
*
* @since API 24
*/
// ===== 粒子结构 =====
interface KDParticle {
angle: number;
radius: number;
size: number;
speed: number;
alpha: number;
offset: number;
hueOffset: number;
}
@ComponentV2
export struct KaleidoscopePage {
// ---- Canvas 上下文 ----
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
// ---- 动画状态 ----
@Local currentAngle: number = 0;
private animationId: number = 0;
// ---- 可调参数(UI 控件绑定) ----
@Local sideCount: number = 6; // N 边形边数 3~12
@Local layerCount: number = 5; // 层数 2~8
@Local rotationSpeed: number = 1.0; // 转速 0.1~3.0
@Local hueBase: number = 200; // 色相基底 0~360
@Local showParticles: boolean = true;
@Local showOutline: boolean = true;
// ---- 粒子系统 ----
private particles: KDParticle[] = [];
// ---- 生命周期 ----
aboutToAppear(): void {
this.initParticles();
this.startAnimation();
}
aboutToDisappear(): void {
this.stopAnimation();
}
// ---- 粒子初始化 ----
private initParticles(): void {
this.particles = [];
for (let i = 0; i < 80; i++) {
this.particles.push({
angle: Math.random() * Math.PI * 2,
radius: 30 + Math.random() * 200,
size: 1 + Math.random() * 3,
speed: 0.1 + Math.random() * 0.6,
alpha: 0.15 + Math.random() * 0.5,
offset: Math.random() * Math.PI * 2,
hueOffset: Math.random() * 60 - 30
});
}
}
// ---- 动画循环 ----
private startAnimation(): void {
const animate = () => {
this.currentAngle += 0.006 * this.rotationSpeed;
this.drawKaleidoscope();
this.animationId = setTimeout(animate, 16); // ~60fps
};
animate();
}
private stopAnimation(): void {
if (this.animationId) {
clearTimeout(this.animationId);
this.animationId = 0;
}
}
// ---- 核心颜色工具 ----
private hsl(h: number, s: number, l: number, a: number): string {
return `hsla(${((h % 360) + 360) % 360},${s}%,${l}%,${a})`;
}
// ---- 主绘制函数 ----
private drawKaleidoscope(): void {
const ctx = this.canvasContext;
const w = ctx.width;
const h = ctx.height;
if (w <= 0 || h <= 0) return;
const cx = w / 2;
const cy = h / 2;
const maxR = Math.min(w, h) * 0.42;
const angle = this.currentAngle;
const n = this.sideCount;
const layers = this.layerCount;
// ========== 1. 清空画布 + 背景 ==========
ctx.clearRect(0, 0, w, h);
// 深色径向渐变背景
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR * 2);
bgGrad.addColorStop(0, `hsla(${this.hueBase},70%,15%,0.6)`);
bgGrad.addColorStop(0.5, `hsla(${this.hueBase},60%,8%,0.3)`);
bgGrad.addColorStop(1, `hsla(${this.hueBase},50%,3%,1)`);
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// ========== 2. 外围光晕 ==========
for (let ring = 0; ring < 3; ring++) {
const ringR = maxR * (0.7 + ring * 0.15);
ctx.beginPath();
ctx.arc(cx, cy, ringR, 0, Math.PI * 2);
ctx.strokeStyle = this.hsl(this.hueBase + ring * 20, 80, 60, 0.06 - ring * 0.015);
ctx.lineWidth = 2 + ring;
ctx.setLineDash([6, 12 + ring * 6]);
ctx.lineDashOffset = -angle * (30 - ring * 8);
ctx.stroke();
}
ctx.setLineDash([]);
// ========== 3. 多层 N 边形旋转叠加(核心万花筒效果)==========
for (let layer = 0; layer < layers; layer++) {
const layerRatio = 1 - layer / (layers + 1); // 1.0 → 0.2
const layerR = maxR * (0.2 + 0.65 * layerRatio);
const layerAngle = angle * (1 + layer * 0.4) + layer * 0.5;
const alpha = 0.25 - layer * 0.025; // 外层亮,内层暗
const hue = this.hueBase + layer * 25 + angle * 10;
const isEven = layer % 2 === 0;
if (alpha <= 0) continue;
// ---- 3a. 绘制 N 边形轮廓(双层描边实现发光感)----
if (this.showOutline) {
ctx.beginPath();
for (let i = 0; i <= n; i++) {
const a = layerAngle + (i % n) * (Math.PI * 2 / n) + (isEven ? 0 : Math.PI / n);
const r = layerR;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = this.hsl(hue, 85, 65, alpha * 1.2);
ctx.lineWidth = 1.5 + 0.5 * Math.sin(angle * 2 + layer);
ctx.shadowColor = this.hsl(hue, 90, 70, 0);
ctx.shadowBlur = 8;
ctx.stroke();
// 内发光细描边
ctx.strokeStyle = this.hsl(hue + 30, 90, 80, alpha * 0.5);
ctx.lineWidth = 0.5;
ctx.shadowBlur = 0;
ctx.stroke();
}
// ---- 3b. 从顶点到中心的连线(辐射线)----
ctx.shadowBlur = 0;
for (let i = 0; i < n; i++) {
const a = layerAngle + i * (Math.PI * 2 / n) + angle * 0.3;
const targetR = layerR * (0.3 + 0.7 * Math.abs(Math.sin(angle * 0.5 + i + layer)));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(
cx + Math.cos(a) * targetR,
cy + Math.sin(a) * targetR
);
ctx.strokeStyle = this.hsl(hue + i * 10, 70, 50, alpha * 0.15);
ctx.lineWidth = 0.5;
ctx.stroke();
}
// ---- 3c. 顶点光点 ----
for (let i = 0; i < n; i++) {
const a = layerAngle + i * (Math.PI * 2 / n);
const px = cx + Math.cos(a) * layerR;
const py = cy + Math.sin(a) * layerR;
const pulseSize = 2 + 2 * Math.sin(angle * 2 + i + layer * 1.5);
const dotAlpha = alpha * (0.5 + 0.5 * Math.sin(angle + i));
const dotGrad = ctx.createRadialGradient(px, py, 0, px, py, pulseSize * 4);
dotGrad.addColorStop(0, '#FFFFFF');
dotGrad.addColorStop(0.3, this.hsl(hue + i * 15, 90, 75, 1));
dotGrad.addColorStop(1, this.hsl(hue + i * 15, 90, 75, 0));
ctx.fillStyle = dotGrad;
ctx.beginPath();
ctx.arc(px, py, pulseSize * 4, 0, Math.PI * 2);
ctx.fill();
}
// ---- 3d. 边中点连线(形成内接星形)----
if (n >= 4) {
const starAlpha = alpha * 0.3;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const a = layerAngle + (i * 2) % n * (Math.PI * 2 / n);
const starR = layerR * (0.6 + 0.2 * Math.sin(angle + i));
const x = cx + Math.cos(a) * starR;
const y = cy + Math.sin(a) * starR;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = this.hsl(hue + 20, 80, 70, starAlpha);
ctx.lineWidth = 0.8;
ctx.setLineDash([2, 3]);
ctx.lineDashOffset = -angle * 15;
ctx.stroke();
ctx.setLineDash([]);
}
}
// ========== 4. 中心光芒(多层径向渐变)==========
ctx.shadowBlur = 0;
for (let c = 0; c < 3; c++) {
const coreR = maxR * (0.05 + c * 0.08);
const coreGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * 3);
coreGrad.addColorStop(0, '#FFFFFF');
coreGrad.addColorStop(0.2, this.hsl(this.hueBase + c * 30, 90, 75, 1));
coreGrad.addColorStop(0.6, this.hsl(this.hueBase + c * 30, 80, 60, 0.3 - c * 0.08));
coreGrad.addColorStop(1, this.hsl(this.hueBase + c * 30, 80, 60, 0));
ctx.fillStyle = coreGrad;
ctx.beginPath();
ctx.arc(cx, cy, coreR * 3, 0, Math.PI * 2);
ctx.fill();
}
// ========== 5. 外部飘浮粒子(沿轨道运动)==========
if (this.showParticles) {
for (const p of this.particles) {
const pAngle = p.angle + angle * p.speed * 0.4;
const pR = p.radius + 20 * Math.sin(angle * 1.5 + p.offset);
const px = cx + Math.cos(pAngle) * pR;
const py = cy + Math.sin(pAngle) * pR;
const pAlpha = p.alpha * (0.4 + 0.6 * Math.sin(angle * 1.2 + p.offset));
const pHue = this.hueBase + p.hueOffset + angle * 20;
ctx.beginPath();
ctx.arc(px, py, p.size, 0, Math.PI * 2);
ctx.fillStyle = this.hsl(pHue, 85, 70, pAlpha);
ctx.fill();
// 粒子外围光晕
const glowGrad = ctx.createRadialGradient(px, py, 0, px, py, p.size * 3);
glowGrad.addColorStop(0, this.hsl(pHue, 90, 80, pAlpha * 0.3));
glowGrad.addColorStop(1, this.hsl(pHue, 90, 80, 0));
ctx.fillStyle = glowGrad;
ctx.beginPath();
ctx.arc(px, py, p.size * 3, 0, Math.PI * 2);
ctx.fill();
}
// 粒子间连线(附近粒子相互连接)
const connectDist = 80;
for (let i = 0; i < this.particles.length; i += 2) {
const p1 = this.particles[i];
const p2 = this.particles[(i + 3) % this.particles.length];
const a1 = p1.angle + angle * p1.speed * 0.4;
const a2 = p2.angle + angle * p2.speed * 0.4;
const r1 = p1.radius + 20 * Math.sin(angle * 1.5 + p1.offset);
const r2 = p2.radius + 20 * Math.sin(angle * 1.5 + p2.offset);
const x1 = cx + Math.cos(a1) * r1;
const y1 = cy + Math.sin(a1) * r1;
const x2 = cx + Math.cos(a2) * r2;
const y2 = cy + Math.sin(a2) * r2;
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectDist) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = this.hsl(this.hueBase, 70, 60, 0.03 * (1 - dist / connectDist));
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
// ========== 6. 画布中心的极点标记 ==========
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fill();
}
// ===================== UI 构建 =====================
build() {
Column({ space: 0 }) {
// ---- 标题 ----
Text('万花筒旋阵')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 16, bottom: 4 })
Text(`${this.sideCount}边形 | ${this.layerCount}层叠加`)
.fontSize(13)
.fontColor(`hsla(${this.hueBase},80%,70%,1)`)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ bottom: 8 })
// ---- Canvas 画布 ----
Canvas(this.canvasContext)
.width('100%')
.layoutWeight(1)
// ---- 控制面板 ----
Column({ space: 8 }) {
// 行1: 边数
Row({ space: 10 }) {
Text('边数')
.fontSize(12)
.fontColor('#999999')
.width(40)
Slider({
value: this.sideCount,
min: 3, max: 12, step: 1
})
.layoutWeight(1)
.trackThickness(4)
.blockColor(this.hsl(this.hueBase, 80, 70, 1))
.trackColor('#FFFFFF30')
.selectedColor(this.hsl(this.hueBase, 80, 70, 1))
.onChange((val: number) => { this.sideCount = val; })
Text(`${this.sideCount}`)
.fontSize(12)
.fontColor('#FFFFFF')
.width(24)
.textAlign(TextAlign.End)
}
.width('100%')
// 行2: 层数
Row({ space: 10 }) {
Text('层数')
.fontSize(12)
.fontColor('#999999')
.width(40)
Slider({
value: this.layerCount,
min: 2, max: 8, step: 1
})
.layoutWeight(1)
.trackThickness(4)
.blockColor(this.hsl(this.hueBase, 80, 70, 1))
.trackColor('#FFFFFF30')
.selectedColor(this.hsl(this.hueBase, 80, 70, 1))
.onChange((val: number) => { this.layerCount = val; })
Text(`${this.layerCount}`)
.fontSize(12)
.fontColor('#FFFFFF')
.width(24)
.textAlign(TextAlign.End)
}
.width('100%')
// 行3: 转速
Row({ space: 10 }) {
Text('转速')
.fontSize(12)
.fontColor('#999999')
.width(40)
Slider({
value: this.rotationSpeed,
min: 0.1, max: 3.0, step: 0.1
})
.layoutWeight(1)
.trackThickness(4)
.blockColor(this.hsl(this.hueBase, 80, 70, 1))
.trackColor('#FFFFFF30')
.selectedColor(this.hsl(this.hueBase, 80, 70, 1))
.onChange((val: number) => { this.rotationSpeed = val; })
Text(`${this.rotationSpeed.toFixed(1)}x`)
.fontSize(12)
.fontColor('#FFFFFF')
.width(36)
.textAlign(TextAlign.End)
}
.width('100%')
// 行4: 色相
Row({ space: 10 }) {
Text('色调')
.fontSize(12)
.fontColor('#999999')
.width(40)
Slider({
value: this.hueBase,
min: 0, max: 360, step: 5
})
.layoutWeight(1)
.trackThickness(4)
.blockColor(this.hsl(this.hueBase, 80, 70, 1))
.trackColor('#FFFFFF30')
.selectedColor(this.hsl(this.hueBase, 80, 70, 1))
.onChange((val: number) => { this.hueBase = val; })
Text(`${this.hueBase}°`)
.fontSize(12)
.fontColor('#FFFFFF')
.width(36)
.textAlign(TextAlign.End)
}
.width('100%')
// 行5: 开关
Row({ space: 16 }) {
Row({ space: 6 }) {
Text('粒子')
.fontSize(12)
.fontColor('#999999')
Toggle({ type: ToggleType.Switch, isOn: this.showParticles })
.selectedColor(this.hsl(this.hueBase, 80, 70, 1))
.switchPointColor('#FFFFFF')
.onChange((isOn: boolean) => { this.showParticles = isOn; })
}
Row({ space: 6 }) {
Text('轮廓')
.fontSize(12)
.fontColor('#999999')
Toggle({ type: ToggleType.Switch, isOn: this.showOutline })
.selectedColor(this.hsl(this.hueBase, 80, 70, 1))
.switchPointColor('#FFFFFF')
.onChange((isOn: boolean) => { this.showOutline = isOn; })
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 14 })
.backgroundColor('#FFFFFF10')
.borderRadius({ topLeft: 16, topRight: 16 })
}
.width('100%')
.height('100%')
.backgroundColor(`hsla(${this.hueBase},50%,5%,1)`)
}
}
Index.ets 挂载代码仅需 17 行:
import { KaleidoscopePage } from './KaleidoscopePage';
@Entry
@ComponentV2
struct Index {
build(): void {
Stack() {
KaleidoscopePage()
}
.width('100%')
.height('100%')
}
}
四、架构设计精析
4.1 组件化结构
整个应用遵循了 “独立文件 + 入口挂载” 的模式。KaleidoscopePage.ets 是一个自包含的 @ComponentV2 结构体,内部承载了全部的状态管理、动画循环、绘制逻辑和 UI 布局。Index.ets 仅作挂载入口,不插手任何业务逻辑。
这种架构的优势在于:
- 职责单一:每个组件只关注自己的核心功能,便于测试和复用
- 独立部署:可以直接在路由表中注册,也可以嵌套在其他页面中
- 状态隔离:
@Local装饰器确保状态变化局限在当前组件内,不会意外影响父组件
4.2 组件生命周期管理
ArkTS 组件的生命周期方法中,aboutToAppear 和 aboutToDisappear 是我们最关心的两个钩子。
aboutToAppear(): void {
this.initParticles();
this.startAnimation();
}
aboutToDisappear(): void {
this.stopAnimation();
}
为什么不在 build() 中初始化? 因为 build() 方法在组件的每次重渲染时都会被调用(包括参数变化引发的重渲染),如果在 build() 中初始化粒子或启动动画,会导致粒子被反复重置、动画定时器被反复创建,造成严重的内存泄漏和性能问题。aboutToAppear 只在组件实例首次挂载到节点树时执行一次,是初始化的正确场所。
为什么要在 aboutToDisappear 中停止动画? 如果组件被销毁(如页面跳转或路由弹出)而动画循环仍在运行,setTimeout 的回调会继续持有对 this.canvasContext 的引用,既造成内存泄漏,又会在已被卸载的 Canvas 上做无意义的绘制。clearTimeout 确保了动画循环随着组件销毁而终结。
这一对生命周期钩子的配合,是组件化动画开发中的经典模式,在任何框架(React useEffect 的 cleanup、Vue onUnmounted)中都有对应的概念。
4.3 渲染流程
每一帧的渲染管道如下:
setTimeout 触发
↓
currentAngle += 0.006 * rotationSpeed
↓
drawKaleidoscope()
├── clearRect 清空画布
├── 绘制深色背景(径向渐变)
├── 绘制 3 圈外围虚线光晕(setLineDash + offset 动画)
├── 循环 layer 层:
│ ├── 绘制 N 边形轮廓(双层描边 shadowBlur 发光)
│ ├── 绘制辐射线(顶点→中心)
│ ├── 绘制顶点光点(径向渐变呼吸)
│ └── N≥4 时绘制内接星形虚线
├── 绘制中心光芒(3 层径向渐变叠加)
├── 绘制粒子系统(80 个 + 距离连线)
└── 绘制中心极点标记
↓
setTimeout(animate, 16) 注册下一帧
4.3 状态管理与响应式更新
@Local 装饰器是 API 24 引入的关键特性。它在 @ComponentV2 中的作用是标记一个可变状态字段,当该字段被赋值时,框架仅重渲染该组件自身,不会冒泡。在我们的代码中:
@Local sideCount: number = 6;
@Local layerCount: number = 5;
@Local rotationSpeed: number = 1.0;
@Local hueBase: number = 200;
@Local showParticles: boolean = true;
@Local showOutline: boolean = true;
@Local currentAngle: number = 0;
其中 currentAngle 是动画帧角度,每 16ms 递增一次;其余六个参数由 Slider / Toggle 控件驱动。由于 @Local 的隔离性,当用户拖动 Slider 改变 sideCount 或 hueBase 时,只有 KaleidoscopePage 被标记为 dirty 并重新调用 build(),Index 不受任何影响。
五、数学原理:N 边形与三角函数的协同
万花筒的核心是对称性,而对称性的数学基础是 正 N 边形的顶点坐标计算。
5.1 顶点坐标公式
对于任意正 N 边形,第 i 个顶点的坐标可以表示为:
θ_i = 起始偏转角 + i * (2π / n)
x_i = 圆心_x + R * cos(θ_i)
y_i = 圆心_y + R * sin(θ_i)
在代码中体现为:
const a = layerAngle + (i % n) * (Math.PI * 2 / n) + (isEven ? 0 : Math.PI / n);
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
这里的 isEven ? 0 : Math.PI / n 实现了奇偶层的交错旋转——偶数层顶点对齐,奇数层旋转半个边距,使不同层之间形成相互穿插的视觉效果。
5.2 三角函数在动画中的多维运用
三角函数(Math.sin / Math.cos)在本项目中至少有五处不同的应用场景:
| 用途 | 表达式 | 效果 |
|---|---|---|
| 顶点坐标 | cos(a)*R, sin(a)*R |
N 边形顶点定位 |
| 线宽呼吸 | 1.5 + 0.5 * sin(angle*2 + layer) |
轮廓宽度周期性变化 |
| 光点脉冲 | 2 + 2 * sin(angle*2 + i + layer*1.5) |
顶点光点放大缩小 |
| 粒子轨道波动 | 20 * sin(angle*1.5 + offset) |
粒子沿轨道径向摆动 |
| 透明度闪烁 | 0.5 + 0.5 * sin(angle + i) |
光点透明度交替明暗 |
这种多路并行的三角函数调制,是产生"万花筒"式复杂视觉效果的根本原因。每一路三角函数都可以视为一个独立的振动模态,它们之间没有整数倍频关系,因此叠加产生的图案具有准周期性(quasi-periodic),永远不会严格重复——这也是为什么观看万花筒永远有新鲜感。
5.3 内接星形连线
当 N ≥ 4 时,代码会绘制一个"每隔一个顶点连线"的星形:
const a = layerAngle + (i * 2) % n * (Math.PI * 2 / n);
(i * 2) % n 看似简单,却在数学上有深意。当 n 为奇数时,(i * 2) % n 会遍历所有顶点一次(因为 2 和 n 互质);当 n 为偶数且 n/2 为奇数时,会遍历 n/2 个顶点。这意味着星形本身也会呈现不同的拓扑结构——五边形产生五角星,六边形产生六芒星(大卫之星),八边形产生八芒星。
六、粒子系统设计
粒子系统是增强视觉深度的点睛之笔。我们设计了 80 个轨道粒子,每个粒子都有独立的属性:
6.1 粒子属性定义
interface KDParticle {
angle: number; // 初始轨道角度 (0~2π)
radius: number; // 轨道半径 (30~230)
size: number; // 粒子大小 (1~4)
speed: number; // 轨道速度系数 (0.1~0.7)
alpha: number; // 基础透明度 (0.15~0.65)
offset: number; // 波动相位偏移 (0~2π)
hueOffset: number; // 色相偏移 (-30~+30)
}
所有属性在 initParticles() 中通过 Math.random() 随机生成,赋予每个粒子独一无二的运动特征。
6.2 双阶段渲染
每个粒子经历两阶段渲染:先画实心小圆点(核心),再画径向渐变光晕(外发光)。
// 核心
ctx.arc(px, py, p.size, 0, Math.PI * 2);
ctx.fillStyle = this.hsl(pHue, 85, 70, pAlpha);
// 光晕
const glowGrad = ctx.createRadialGradient(px, py, 0, px, py, p.size * 3);
glowGrad.addColorStop(0, this.hsl(pHue, 90, 80, pAlpha * 0.3));
glowGrad.addColorStop(1, this.hsl(pHue, 90, 80, 0));
这种"核+晕"的渲染手法在粒子系统乃至星云模拟中都很常见。核心提供颜色饱和度和不透明度,光晕提供柔和的发光边缘,两者结合产生类似恒星或萤火虫的观感。
6.3 粒子间距离连线
为了模拟"星云网络"效果,代码对粒子进行了配对连线:
if (dist < connectDist) {
ctx.strokeStyle = this.hsl(this.hueBase, 70, 60, 0.03 * (1 - dist / connectDist));
ctx.lineWidth = 0.5;
ctx.stroke();
}
连线透明度随距离衰减(1 - dist / connectDist),形成"近浓远淡"的渐变网。connectDist = 80 是一个经验值,在粒子密度为 80 的情况下,大约会产生 30~50 条可见连线,不至于太密集也不至于太空旷。
七、颜色系统:HSL 模型的优势
整个项目完全采用 HSL 色模型,没有使用任何十六进制或 RGB 字面量(除了 #FFFFFF 纯白)。我们封装了一个辅助方法:
private hsl(h: number, s: number, l: number, a: number): string {
return `hsla(${((h % 360) + 360) % 360},${s}%,${l}%,${a})`;
}
为什么要用 HSL?有三个理由:
1. 色相循环天然适配动画
hue 参数是一个数值角度,可以随意加减。代码中让色相随动画角度漂移:
const hue = this.hueBase + layer * 25 + angle * 10;
每帧 angle 增加约 0.006,对应色相每帧偏移约 0.06°,大约 6000 帧(约 100 秒)完成一个完整色相循环。这种缓慢的色相漂移让画面始终在变化,不会视觉疲劳。同时值得注意的是,layer * 25 让每层之间的色相相差 25°,这意味着在 5 层情况下,最内层和最外层的色相差可达 100°——从蓝色渐变到青绿色再过渡到绿色,形成了丰富的色彩梯度。
2. 饱和度/明度独立控制
HSL 模型将"颜色本质"(色相)与"颜色表现"(饱和度、明度、透明度)分离。这让代码可以专注于色相计算,而饱和度/明度保持恒定值或简单函数——极大的减少了调色心智负担。
具体来说,代码中不同元素的 HSL 参数分配如下:
| 元素 | 饱和度(S) | 明度(L) | 透明度(A) |
|---|---|---|---|
| 多边形轮廓(外发光) | 85% | 65% | alpha * 1.2 |
| 多边形轮廓(内描边) | 90% | 80% | alpha * 0.5 |
| 辐射线 | 70% | 50% | alpha * 0.15 |
| 顶点光点(渐变中心) | 90% | 75% | 1.0 |
| 粒子核心 | 85% | 70% | pAlpha |
| 粒子光晕 | 90% | 80% | pAlpha * 0.3 |
| 外围虚线环 | 80% | 60% | 0.06 ~ 0.03 |
仔细看这个分配表:轮廓的外发光使用较低明度(65%)和较高透明度,营造"从背后透出的光";内描边则高明度(80%)低透明度,制造"表面的光泽";辐射线刻意压低饱和度和透明度(50%/0.15),作为背景纹理不应喧宾夺主。这种分层级的色彩策略,让画面既有深度又不杂乱。
3. 动态生成颜色渐变
在径向渐变中:
coreGrad.addColorStop(0, '#FFFFFF');
coreGrad.addColorStop(0.2, this.hsl(this.hueBase + c * 30, 90, 75, 1));
coreGrad.addColorStop(0.6, this.hsl(this.hueBase + c * 30, 80, 60, 0.3 - c * 0.08));
coreGrad.addColorStop(1, this.hsl(this.hueBase + c * 30, 80, 60, 0));
从纯白 → 高饱和鲜艳色 → 暗色 → 透明,HSL 让这种过渡可以参数化表达,改一行 hueBase 就能切换整个应用的主题色。
值得注意的一个设计细节是 addColorStop 的位置选择:渐变中心 0% 处用纯白而非纯色,是为了模拟强光光源的"过曝"效果——任何强光中心看起来总是白色的。20% 处才是该层的本色,60% 处开始褪色到 100% 处完全透明,这种"中间宽两边窄"的色标分布,比均匀分布更接近真实的光晕衰减曲线。
八、性能优化实践
在 60fps 的 Canvas 动画中,性能是关键。每帧只有约 16ms 的时间来执行所有绘制操作。以下是我们采用的主要优化策略:
8.1 提前退出检查
if (w <= 0 || h <= 0) return;
在 drawKaleidoscope 函数最开头,检查 Canvas 的宽度和高度是否有效。当页面尚未 layout(宽高为 0)时,直接跳过绘制,避免无效计算。
8.2 分层裁剪
if (alpha <= 0) continue;
当层的透明度衰减到 0 以下时,跳过整个该层的所有绘制操作。这在 layerCount 为 8 时特别有用,后几层的透明度可能低于 0,无需浪费时间渲染不可见的像素。
8.3 setTimeout 而不是 requestAnimationFrame
这是一个有意思的选择。在 HarmonyOS 的 ArkTS 环境中,requestAnimationFrame 的调用时机取决于 UI 线程的帧调度,而 setTimeout 提供了更确定的时间间隔。使用 setTimeout(animate, 16) 大约对应 60fps(1000/60 ≈ 16.67ms),且不会因为 UI 线程拥堵而丢失帧。
当然,CPU 繁忙时 setTimeout 也可能被延迟,但对于 Canvas 动画而言,“略晚几毫秒"的视觉影响远小于"丢失一帧”。这是我们权衡后的选择。
8.4 减少路径重建
在绘制 N 边形轮廓时,代码尝试在一个 beginPath() ~ closePath() 对中完成所有顶点的连接:
ctx.beginPath();
for (let i = 0; i <= n; i++) {
const a = layerAngle + (i % n) * (Math.PI * 2 / n) + (isEven ? 0 : Math.PI / n);
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
避免为每条边创建一个独立的路径,而是用 moveTo + 多条 lineTo 构建闭合路径,大幅降低 Canvas 底层路径管理的开销。
8.5 避免在热路径中创建对象
代码中没有在 drawKaleidoscope 的循环体内创建临时数组或对象。粒子数组 this.particles 在 aboutToAppear 时一次性创建,之后在动画循环中只读取不重建。颜色字符串使用模板字面量(hsla(...))而非对象封装,减少 GC 压力。
8.6 shadowBlur 的有条件使用
shadowBlur 是 Canvas 中开销较大的操作之一。每设置一次阴影,渲染引擎需要额外生成一个模糊纹理并与目标像素进行卷积混合。代码中对此做了精确的控制:
// 外发光时启用阴影
ctx.shadowBlur = 8;
ctx.stroke(); // 消耗阴影渲染
ctx.shadowBlur = 0; // 立即关闭
shadowBlur 只在需要外发光效果的多边形轮廓绘制时(步骤 3a 的外描边)启用,且用完即关。在辐射线、顶点光点、粒子等所有其他绘制步骤前,都显式地将 shadowBlur 置为 0,避免不必要的阴影计算扩散到后续路径。
8.7 Canvas 尺寸自适应
Canvas 的宽高由父容器约束决定,代码中使用 ctx.width 和 ctx.height 动态获取当前宽高:
const w = ctx.width;
const h = ctx.height;
if (w <= 0 || h <= 0) return;
这带来了一个重要的好处:屏幕旋转或窗口大小变化时完全不需要额外适配代码。Canvas 的宽高由布局系统自动更新,drawKaleidoscope 每次绘制时读取最新的宽高值,计算出适配的中心点和最大半径,所有元素自动居中。这也是声明式布局框架相比传统命令式 UI 的优势之一。
8.8 帧率与设备适配
setTimeout(animate, 16) 的目标帧率是 60fps(1000/16 ≈ 62.5fps,留有 2.5ms 余量)。在实际设备上,不同性能等级的 HarmonyOS 设备可能表现出不同的帧率稳定性:
- 旗舰设备(如 Mate 60 系列):稳定 60fps,
angle累加均匀,视觉效果最流畅 - 中端设备(如 Nova 系列):可能在 50~60fps 之间波动,偶尔出现轻微卡顿
- 低端设备:可能降至 30fps,此时
angle累加间隔拉长,画面会感觉"跳帧"
对于需要在低端设备上保持流畅的场景,可以考虑 layerCount 的自动降级——在 aboutToAppear 中通过 getSystemInfo 获取设备等级,动态调整默认层数。
九、可视化参数调优
为了让万花筒达到最佳视觉效果,我们配置了 5 个可交互参数,并通过 Slider 和 Toggle 暴露给用户:
| 参数 | 类型 | 范围 | 步进 | 默认值 | 视觉影响 |
|---|---|---|---|---|---|
| 边数 | Slider | 3~12 | 1 | 6 | 对称图案的复杂度 |
| 层数 | Slider | 2~8 | 1 | 5 | 视觉纵深与层次感 |
| 转速 | Slider | 0.1~3.0 | 0.1 | 1.0 | 动画快慢节奏 |
| 色调 | Slider | 0~360 | 5 | 200 | 整体色彩倾向 |
| 粒子 | Switch | on/off | - | on | 粒子系统显隐 |
| 轮廓 | Switch | on/off | - | on | 多边形轮廓显隐 |
每一个参数都经过精心的数值范围设计:
- 边数 3~12:3 边形(等腰三角形)和 4 边形(正方形)是最简单的对称形式,5~8 边形是最具美感的中间区段,9~12 边形则趋近圆形,适合实验性观察
- 层数 2~8:2 层太简单,8 层接近性能上限(每层 4 个子绘制区域 × 8 = 32 个循环块)
- 转速 0.1~3.0:0.1 倍几乎静止,适合观察单帧结构;3.0 倍快速旋转,产生残影和融合效果
- **色相 0360**:覆盖整个色环,不同色相值带来截然不同的情感体验——蓝色(180240)冷静深邃,红色(030)热情奔放,绿色(90150)自然平和
十、扩展思路
以当前的万花筒旋阵为基础,可以从以下几个方向进一步扩展:
10.1 触摸交互
在 Canvas 上绑定 onTouch 事件,监听手指滑动来实时调整参数。例如,双指捏合改变边数,单指旋转改变色相,让交互更有沉浸感。
Canvas(this.canvasContext)
.onTouch((event: TouchEvent) => {
// 根据手势调整参数
})
10.2 音频驱动
使用 AudioCapturer 或 AudioRenderer 捕获麦克风输入或播放音乐的频谱数据,将音频能量映射到万花筒的参数上——低频控制层数,中频控制半径,高频控制粒子大小。实现"音乐可视化"。
10.3 录制与分享
利用 CanvasRenderingContext2D 的 toDataURL 方法,将当前帧导出为图片。配合 @ohos.multimedia.media 的屏幕录制能力,可以将一段万花筒动画录制成视频并分享。
10.4 多种对称模式
当前的 N 边形是基于旋转对称(C_n)。可以增加二面体对称(D_n,包含镜像反射)和螺旋对称(每旋转一个角度后半径缩放,类似斐波那契螺旋),让万花筒的图案类型更加丰富。
10.5 GLSL Shader 版本
当 N 边数超过 12 或者层数超过 8 时,CPU 上的 Canvas 绘制会逼近性能瓶颈。此时可以探索使用 CanvasRenderingContext2D 的 WebGL 模式,或者直接使用 @ohos.graphics 来编写自定义 Shader,在 GPU 上并行计算所有顶点和颜色。
十一、总结
本文以一个完整的 N 边形万花筒 Canvas 动画项目为载体,详细阐述了在 HarmonyOS NEXT(API 24)上开发图形动画应用的全流程。从组件架构设计、数学原理、粒子系统、颜色模型到性能优化,涵盖了视觉计算应用开发的多个核心维度。
万花筒的魅力在于:简单的规则 + 持续的迭代 = 复杂的不可预测之美。N 个顶点、N 条连线、N 层叠加——每一个 N 都是一个自由度,这些自由度通过三角函数调制、色相漂移、透明度叠加等方式交织在一起,形成了每帧都在演化的视觉奇观。
希望这篇解析能为你在 HarmonyOS 平台上开发图形密集型应用提供参考和启发。
代码仓库
完整项目结构:
entry/src/main/ets/pages/
├── Index.ets # 入口挂载(17 行)
└── KaleidoscopePage.ets # 全部逻辑(474 行)
API 24 = HarmonyOS NEXT 6.1.1,compatibleSdkVersion 与 targetSdkVersion 均为 24。
本文对应的项目配置信息:
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)"
更多推荐


所有评论(0)