1. 为什么用几行代码而不是Photoshop:中心对称图案的开发场景
先说我为什么会对这个题目感兴趣。前阵子做鸿蒙桌面小组件,需要一个类似万花筒的底纹图标,素材库里扒了半天,不是版权不合适就是风格对不上,后来索性直接用Canvas画了一个。画的过程中发现,中心对称图案这东西,数学上看着挺玄乎,代码实现其实非常直观——一圈旋转、重复绘制,几十行就能搞定一个看起来相当复杂的图形。这也是HarmonyOS应用实例里这类练习最有价值的地方:它不是为了画一朵花给你看,而是让你在动手过程中把状态管理、Canvas绘制、事件交互这几块基础能力全部串起来。
中心对称图案,按定义说就是图形绕某个中心点旋转一定角度后能和自身重合。最常见的像五角星旋转72度、雪花旋转60度,都属于这类。实际开发中,这种图案的用途远比你想的广:加载动画里的转圈菊花、数据大屏里的雷达图背景、游戏里的技能范围提示、甚至是自定义键盘的背景纹理,全是中心对称的变体。你只要掌握了“旋转-复制-重绘”这一个核心逻辑,这些场景基本都能复用。
我在这个项目里选型时也纠结过两种方案:一是用Canvas 2D手绘,二是用XComponent接OpenGL。对比下来,如果只是做2D图案设计、交互调节参数,Canvas完全够用,而且代码量小、调试方便;XComponent适合做大量粒子、3D变换那种重度渲染场景,杀鸡用牛刀没必要。文章里的实现默认走Canvas 2D这条路线,开发环境是DevEco Studio,语言ArkTS,API版本以HarmonyOS 4.2为基准。
适用人群方面,这个实例的难度区间比较友好:你刚学完ArkTS基础语法,想拿一个小项目练手,很合适;你已经写过几个页面,但对Canvas这块还是空白,这篇也能帮你快速补齐;甚至你打算做自定义绘图表单控件、小游戏,中心对称的绘制思路同样能给你一些启发。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 绘图核心:Canvas画布坐标系与对称绘制的底层逻辑
2.1 Canvas在ArkTS里的基本用法
HarmonyOS的Canvas不是HTML5那种<canvas>标签,而是ArkUI框架里的一个组件,配套一个CanvasRenderingContext2D对象。用法上先声明一个RenderingContextSettings,创建上下文,再把上下文绑定到Canvas组件上,之后所有绘制指令都通过这个上下文对象发出。
typescript复制@Entry
@Component
struct SymmetricPattern {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
build() {
Column() {
Canvas(this.context)
.width('100%')
.height(400)
.backgroundColor('#FFFFFF')
.onReady(() => {
this.drawPattern()
})
}
}
drawPattern() {
// 绘制逻辑
}
}
onReady是Canvas组件的生命周期回调,表示画布已经准备好可以绘制了。这一步很容易被忽略,如果直接在前面的aboutToAppear里调drawPattern,大概率会拿到一个空画布,因为组件还没完成布局。我一开始就在这踩过坑:Canvas宽度高度都没确定,画布上下文拿不到有效的尺寸,画出来的东西位置全乱。
另外注意RenderingContextSettings构造函数里的true,这个参数开启抗锯齿。绘制中心对称图案时会有大量弧线、斜线重叠,不开抗锯齿边缘会像锯齿一样参差不齐,尤其在高分辨率屏幕上会更明显,所以这个参数建议默认就开。
2.2 坐标轴旋转:理解save/restore为什么是万能的
中心对称绘制的核心不是去算每个图形坐标,而是利用Canvas的坐标变换。Canvas的坐标系默认原点在左上角,x轴向右,y轴向下。当你调用rotate(角度)时,整个坐标系绕当前原点旋转,之后画的每个图形坐标都基于旋转后的坐标系。
这里有个关键点:rotate接受的是弧度而不是角度。180度 = Math.PI,90度 = Math.PI / 2。我见过不少新手直接把角度值传给rotate,结果图形偏得离谱。转换公式很简单:弧度 = 角度 * Math.PI / 180。
typescript复制// 错误示例:直接传角度值
context.rotate(45) // 实际旋转了45弧度,约2578度
// 正确示例
context.rotate(45 * Math.PI / 180)
接下来就是save和restore。这两个方法构成的“存档-读档”机制,是绘制重复性图案的基石。save()把当前坐标系状态压入栈中,restore()从栈顶弹出状态并恢复。配合循环,就能在同一个中心点重复绘制多次,而不用担心坐标系越转越乱。
2.3 从花瓣到万花筒:一个8片花瓣的完整绘制代码
中心对称图案的基本套路是这样的:先把坐标原点平移到图案中心,然后在循环里不断旋转坐标系并绘制同一个基础图形单元。基础单元可以是一段弧、一条路径、一个圆、或者一张图片,组合起来就是完整图案。
下面实现一个最经典的8片花瓣图案,每一片都是半圆弧,通过旋转8次覆盖整圆。
typescript复制drawPattern() {
const ctx = this.context
const width = this.context.width
const height = this.context.height
const centerX = width / 2
const centerY = height / 2
const petals = 8
const radius = 80
ctx.clearRect(0, 0, width, height)
ctx.save()
// 把原点移到画布中心
ctx.translate(centerX, centerY)
for (let i = 0; i < petals; i++) {
ctx.save()
// 每个花瓣旋转 i * (360 / petals) 度
ctx.rotate(i * 2 * Math.PI / petals)
ctx.beginPath()
// 以(0, -radius/2)为圆心,radius/2为半径画半圆
ctx.arc(0, -radius / 2, radius / 2, 0, Math.PI, false)
ctx.fillStyle = i % 2 === 0 ? '#FF6B6B' : '#4ECDC4'
ctx.fill()
ctx.restore()
}
ctx.restore()
}
这个例子里,弧线的圆心在(0, -radius/2),半圆朝下画。旋转到不同角度后,这些半圆就像花瓣一样绕中心铺开。交替填色让相邻花瓣颜色不同,图案更有层次感。
实际跑一下你会发现:代码量确实不大,但得到的效果已经很像一朵花了。这也正是参数化绘图最爽的地方——你想改成12片花瓣、半径调到120、颜色换成渐变色,只需要改参数,不用重画。这种“数据驱动图案”的思路,和传统设计工具里手动摆图形是完全不同的节奏。
3. 让图案“活”起来:交互参数与状态管理实战
3.1 参数化设计:用Slider动态控制对称阶数
静态画一幅图只能算练手,真正体现应用价值的地方是让用户能实时调整参数。中心对称图案的“对称阶数”是最核心的一个维度——旋转一周分成几份,决定了图案的基本骨架。阶数太少显得稀疏,阶数太多则可能糊成一团。对于不同基础图形,最佳阶数差异很大,所以最好交给用户去试。
HarmonyOS提供了Slider组件,默认范围0到100,支持min、max、step属性。我用它来控制花瓣数量,取值范围可以设成3到24。调整阶数后重新绘制,效果立竿见影。
typescript复制@State petalCount: number = 8
Slider({ value: this.petalCount, min: 3, max: 24, step: 1 })
.onChange((value: number) => {
this.petalCount = value
this.drawPattern()
})
.width('80%')
这里@State装饰器是ArkTS状态管理的核心。petalCount一旦变化,UI层会感知到;我在onChange里手动调一次drawPattern,就能拿到最新的参数去重绘。这是HarmonyOS里比较典型的“状态驱动渲染”模式。
要注意的是,滑块拖动过程中会连续触发onChange,也就是每移动一格都会重绘一次。如果绘制逻辑特别复杂,可能造成卡顿。优化方案有两种:一是用onChangeEnd代替onChange,拖动结束才触发一次重绘;二是在绘制函数里做节流,比如200毫秒内只执行一次。小项目里用前一种就够了,实现还简单。
3.2 旋转角度与颜色方案:两个Slider控制多维外观
除了对称阶数,图案的初始旋转角度、配色方案同样可以参数化。初始旋转角度会影响整个图案的“姿态”,比如花瓣从正上方开始还是从斜上方开始。配色则需要更多逻辑,给它一个颜色组数组,按序号循环取色,切换颜色方案时只需替换数组。
typescript复制@State baseAngle: number = 0
@State colorScheme: number = 0
private colorSchemes: string[][] = [
['#FF6B6B', '#4ECDC4', '#FFD93D'],
['#6C5CE7', '#A29BFE', '#FD79A8'],
['#00B894', '#00CEC9', '#FDCB6E']
]
getCurrentColor(index: number): string {
const scheme = this.colorSchemes[this.colorScheme % this.colorSchemes.length]
return scheme[index % scheme.length]
}
baseAngle作为全局旋转偏移量,在绘制循环外先旋转一次。这样所有花瓣会整体转起来,而不是每个花瓣单独偏移,效果看起来是整幅图在旋转。
旋转角度这个参数,在设计和后续做动画时都很关键。比如想让图案像风车一样转动,只需用一个定时器持续累加baseAngle并重绘即可。对于静态预览场景,给个滑块手动调角度,也能帮助用户从不同角度感受图案结构。
3.3 点击重置与随机生成:事件绑定的细节
交互不只是滑块,手势事件也值得加。常见的操作是“点击画布随机换一个图案”和“双击重置为默认参数”。HarmonyOS的Canvas组件支持.onClick和.onTouch等事件。
typescript复制Canvas(this.context)
.onClick(() => {
// 随机切换配色
this.colorScheme = Math.floor(Math.random() * this.colorSchemes.length)
this.drawPattern()
})
.onDoubleClick(() => {
// 重置为默认参数
this.petalCount = 8
this.baseAngle = 0
this.colorScheme = 0
this.drawPattern()
})
一个小提醒:onClick和onDoubleClick同时存在时,单击会有一定的触发延迟,因为系统需要等一会儿确认用户是否还会再点一次,这是个系统级行为,无法完全消除。做设计工具类应用时,尽量别把“单击”和“双击”绑定到互相冲突的操作上。
事件绑定还有一个容易被忽略的点:drawPattern里用到的参数都是从@State变量读取的。在事件回调里修改@State变量后,组件状态更新是异步的,但你在回调里立刻调用drawPattern,读到的是最新值还是旧值?实测下来是旧值,因为状态赋值和UI渲染之间有个微任务间隔。解决办法是先把新值存到一个局部变量,传给drawPattern作为入参。
typescript复制.onClick(() => {
const newScheme = (this.colorScheme + 1) % this.colorSchemes.length
this.colorScheme = newScheme
this.drawPattern() // 这里如果不传参,drawPattern内部读取this.colorScheme可能还是旧值
})
稳妥的做法是让drawPattern接受一个配置对象,显示传入所有参数,内部不再读@State。这也是我后来重构代码时改成的模式。这样既能避免状态时序问题,也让绘制函数变成纯函数,方便单测和复用。
4. 表现力升级:渐变色、径向渐变与动画循环
4.1 线性渐变改为径向渐变:让图案更有立体感
基础单色填充只能说“能看”,稍微上点档次就得用渐变。Canvas的createRadialGradient可以创建径向渐变,非常适合圆形的中心对称图案——颜色从内到外自然过渡,视觉上会有一个从中心向外扩散的层次感。
typescript复制createRadialGradient(x0, y0, r0, x1, y1, r1)
参数里的r0是渐变起始圆半径,r1是结束圆半径。比如从圆心半径0到半径150的渐变:
typescript复制const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius * 2)
gradient.addColorStop(0, '#FFD93D')
gradient.addColorStop(0.5, '#FF6B6B')
gradient.addColorStop(1, '#6C5CE7')
ctx.fillStyle = gradient
中心对称图案用径向渐变有个特别好的地方:不管图案旋转多少次,渐变中心始终在画布中心,所有花瓣共享同一个渐变,整体感非常强,不会出现每个花瓣各自一个渐变的割裂感。前提是渐变坐标系的圆心要先通过translate移到画布中心,否则默认原点是左上角,画出来的渐变会偏移。
4.2 动态旋转动画:requestAnimationFrame的用法
静态图案调好之后,我想让它转起来。动态效果对图案设计类应用来说是加分项——用户能看到不同角度下的图案形态,也能直接当成动态壁纸或者屏保素材。
HarmonyOS中实现动画常用的方式有:animateTo隐式动画、Canvas.createAnimation、以及最底层的requestAnimationFrame。对于这种需要持续刷新Canvas内容的场景,前两种都不太适用——animateTo动画的是组件属性,不是Canvas内容;createAnimation偏UI控件动画。最直接的还是requestAnimationFrame。
typescript复制private animatorId: number = -1
@State isAnimating: boolean = false
startAnimation() {
if (this.isAnimating) {
return
}
this.isAnimating = true
const animate = () => {
this.baseAngle = (this.baseAngle + 1) % 360
this.drawPattern()
this.animatorId = requestAnimationFrame(animate)
}
this.animatorId = requestAnimationFrame(animate)
}
stopAnimation() {
if (this.animatorId !== -1) {
cancelAnimationFrame(this.animatorId)
this.animatorId = -1
}
this.isAnimating = false
}
注意,这里每一帧都改了this.baseAngle并触发一次drawPattern。在requestAnimationFrame连续回调里,频率约为每秒60次。这个节奏下如果绘制函数本身耗时低于16毫秒,动画就是流畅的;如果绘制复杂度太高,可以改成每两帧或每三帧旋转一次,牺牲一点平滑度来保证不掉帧。
实测下来,我这朵花瓣数24、带径向渐变的图案,单次绘制耗时大概在3到5毫秒,跑60帧毫无压力。如果是更复杂的图案,比如几百个粒子的效果,可能需要配合Canvas的离屏渲染或者降低帧率来做平衡,这个我们后面再说。
4.3 绘制状态与页面生命周期的配合
动画跑起来之后,有一个很容易忽略的问题:页面切到后台再回来,requestAnimationFrame可能还在跑,但页面已经不可见,白白消耗资源。规范的做法是在页面的onPageHide里停止动画,onPageShow里再启动。
typescript复制onPageHide() {
this.stopAnimation()
}
onPageShow() {
if (this.isAnimating) {
this.startAnimation()
}
}
这里有个细节:onPageShow里再调startAnimation时,isAnimating已经被stopAnimation置为false了,所以能正常启动新的动画循环。但如果页面是首次加载,onPageShow会先于Canvas的onReady触发,此时this.context可能还没初始化好,贸然启动动画会报错。稳妥的写法是在onReady里加一个标志位,等Canvas准备好之后才允许启动动画。
typescript复制@State canvasReady: boolean = false
.onReady(() => {
this.canvasReady = true
this.drawPattern()
})
onPageShow() {
if (this.canvasReady && this.isAnimating) {
this.startAnimation()
}
}
实战里这类边界场景很多。画布准备、页面可见性、动画状态、用户参数,四个条件交叉,不做保护就会有各种偶发崩溃。合理的状态管理不只让UI好看,更是让程序稳的关键。
5. 真机调试与参数实测:hdb和无线调试的几个血泪经验
5.1 hdb命令行到底能做什么
写代码、跑模拟器、看效果,这是开发的基础循环。但中心对称图案这种依赖视觉细节的效果,模拟器和真机显示往往有差异。HDC(HarmonyOS Device Connector)的命令行工具在DevEco Studio里经常被调用,很多人不知道它也能单独使用,用来装包、看日志、抓取界面布局。新版HarmonyOS中,hdb作为调试命令被大量提及,我测试时用到最多的几个命令:
bash复制# 列出当前连接的设备
hdb devices
# 安装应用
hdb install com.example.symmetricpattern.hap
# 启动应用
hdb shell aa start -a EntryAbility -b com.example.symmetricpattern
# 查看应用日志
hdb shell hilog | grep SymmetricPattern
# 抓取屏幕截图
hdb shell snapshot_display -f /data/local/tmp/screen.png
hdb file recv /data/local/tmp/screen.png ./screen.png
有了这些命令,即使不用DevEco Studio的图形化调试界面,也能在命令行里完成部署、运行、看日志的闭环。对需要批量验证不同参数下的图案效果时,截图命令特别实用——写个循环改baseAngle、截图、拼成一张长图,肉眼对比图案变化,比在模拟器里手动拖滑块高效得多。
5.2 无线WiFi调试:配置步骤和踩坑记录
HarmonyOS 4.2开始,无线调试的功能更完善了。其实质就是让开发机通过WiFi网络连接实体设备,不用每次插USB线,对频繁真机验证、长时间看渲染效果的场景非常方便。基础配置流程我记录一下:
第一步,手机上打开开发者模式,进入系统与更新 > 开发人员选项,打开无线调试。
第二步,用USB连接电脑和手机,通过hdb建立信任关系:
bash复制hdb tconn 192.168.1.100:5555
hdb devices
第三步,断开USB,之后就可以通过WiFi持续调试了。
踩坑经验主要有三个。一是IP地址会变。手机连的WiFi如果开了DHCP,IP可能几天后就不是同一个了,重新连接前先用hdb devices扫一下,实在不行重新USB连接一次。二是防火墙。Windows电脑偶尔会因为防火墙拦截5555端口的连接,表现为hdb tconn能成功但hdb install一直超时,这时需要在防火墙里放行HarmonyOS相关进程。三是有时候网络环境有AP隔离,手机和电脑虽然连同一个WiFi但互相不通,这个比较隐蔽,排查了很久才发现是路由器设置问题。
这些问题的共性就是:无线调试虽然方便,但不是零成本。我的建议是,日常小改动模拟器够用,涉及视觉细节的调试再连真机,这样效率最高。
5.3 真机渲染性能观察:如何确认动画不丢帧
中心对称图案动画在模拟器上流畅,不代表真机也流畅。用真机测试时,我要确认动画是否稳定在60帧。HarmonyOS提供了hilog里的性能日志,也可以直接看渲染线程的帧率信息。
有一种更直观的方法:在动画循环里自己计量相邻两次刷新的时间戳差值,超过16.7ms就累计一次“掉帧计数”,显示在界面上。
typescript复制private lastFrameTime: number = 0
private droppedFrames: number = 0
animate() {
const now = Date.now()
if (this.lastFrameTime !== 0) {
const delta = now - this.lastFrameTime
if (delta > 20) {
this.droppedFrames++
}
}
this.lastFrameTime = now
// 绘制逻辑...
}
这招虽然土,但效果很好,能直接量化当前绘制逻辑的实际负载。我测试24花瓣+径向渐变+动态旋转时,掉帧几乎为零,说明这个方案的性能余量很大,后续就算把图案复杂度再翻倍也很稳。
6. 编排一个可复用的绘图引擎:从单页Demo到通用组件
6.1 设计思路:把绘制逻辑从页面里抽离
写到这里,drawPattern还在页面组件内部,参数一多,代码就有点臃肿。为了后续复用,我把它抽成了一个独立的绘图引擎类,用面向对象的方式管理参数和绘制逻辑。
typescript复制export interface PatternConfig {
petalCount: number
baseAngle: number
colorSchemeIndex: number
radius: number
hasGradient: boolean
isAnimated: boolean
}
export class SymmetricPatternEngine {
private context: CanvasRenderingContext2D
private config: PatternConfig
constructor(context: CanvasRenderingContext2D, config: PatternConfig) {
this.context = context
this.config = config
}
updateConfig(newConfig: Partial<PatternConfig>) {
this.config = { ...this.config, ...newConfig }
}
draw() {
// 基于this.config绘制
}
}
页面里只需要维护一个PatternConfig状态对象,任何交互操作都先修改config,再调用engine.draw()。这样页面代码简洁,绘图逻辑也能独立测试。将来要是做图案生成器App,这个引擎可以直接平移到另一个项目,不用大改。
6.2 保存图案:把Canvas内容导出为图片
图案设计得再好看,不能导出就没法分享或者当壁纸用。HarmonyOS的Canvas支持getPixelMap或toDataURL方式导出图像。我用的是canvasToDataURL(或者对应API版本提供的像素图接口),导出后存到相册。
typescript复制import { image } from '@kit.ImageKit'
async savePattern() {
const pixelMap = this.context.getPixelMap(0, 0, this.context.width, this.context.height)
// 压缩、保存到相册
// 这里需要申请相册写权限,并在配置文件中声明 ohos.permission.WRITE_IMAGEVIDEO
}
这里有权限坑:HarmonyOS的相册权限模型比较严格,涉及ohos.permission.WRITE_IMAGEVIDEO等权限,需要在module.json5里声明,还要在运行时做requestPermissionsFromUser请求,否则保存时报错“Permission denied”。一次性把权限声明和请求逻辑写好,后面就不会反复折腾。
6.3 创意扩展思路:不只有“花瓣”
中心对称图案的绘制核心是“基础单元 + 旋转复制”,基础单元换成任意图形,就能得到完全不同的效果。我试过几种,都很有意思:
- 线条网格:基础单元是几根交叉线段,旋转后形成类似曼陀罗的线稿图。
- 圆环叠加:基础单元是不同半径的同心圆环,旋转后产生类似水波纹的涟漪效果。
- 文字符号:基础单元是一个字符,比如字母或符号,旋转后变成民族风纹样。
- 图片素材:基础单元是一张小图标,旋转后变成类似万花筒效果。
这些变化不需要改引擎核心,只替换draw()函数里的“单元绘制”部分即可。所以引擎设计时,我会把“基础单元绘制”单独拆成一个可替换的函数。
6.4 参数组合与灵感收集:用随机遍历找设计
有时候用户不是想要某个确定的图案,而是想“随便看看有什么好看”。这时可以做一个“随机灵感”功能:一键生成随机参数组合,预览图案。实现方式也不复杂。
typescript复制randomizeConfig() {
this.config = {
petalCount: Math.floor(Math.random() * 18) + 3,
baseAngle: Math.floor(Math.random() * 360),
colorSchemeIndex: Math.floor(Math.random() * this.colorSchemes.length),
radius: 60 + Math.floor(Math.random() * 60),
hasGradient: Math.random() > 0.5
}
}
我经常用这个能力找配色灵感。随机参数偶尔能蹦出意想不到的惊艳组合——这可能就是算法生成设计的魅力所在。做设计工具类应用时,这种“随机遍历”加上“手动微调”的组合,是留住用户的一大利器。
7. 最后分享几个实际使用中的小心得
这个项目做到后面,已经不再只是“画一朵花”了,更像是一个小型的参数化绘图框架。几个心得想分享给准备动手的朋友。
第一,中心对称图案的核心不是代码,而是数学。rotate循环背后是旋转变换的几何直觉。如果你能把“旋转多少度、复制几份、单元长什么样”这三个问题想清楚,代码只是最后一步的翻译。很多同学卡住,往往不是因为不会写代码,而是没在纸上先画出图形分解图。建议动手前先在纸上画一画,把单元、角度、旋转次数标注清楚,再写代码就顺了。
第二,状态管理在绘图应用里的重要性会被放大。普通页面里,状态更新和UI渲染的脱节影响不大;绘图应用里,每次重绘都是一次“全量刷新”,状态时序稍有偏差,图案就会闪或错。推荐一开始就把绘图函数设计成“纯函数”,接收参数、只负责画,不掺合页面状态。
第三,Canvas性能不是玄学,是能算出来的。单次绘制时间、绘制密度、动画帧间隔,这些都能实际测量。与其凭感觉优化,不如先量化再动手。保持60帧的目标下,只要单次绘制不超过15毫秒基本就稳。
第四,真机调试要趁早。图案的色彩、透明度、抗锯齿效果,模拟器和真机多少会有差异。尤其中心对称图案这种大量重叠的半透明区域,不同屏幕的呈现效果可能差挺多。建议从初期就养成定期连真机的习惯,不要等所有功能写完再上真机,那时出了问题,定位成本会高很多。
这套代码我还在持续迭代,最近在尝试把离屏渲染加进去,让更多重复单元能预先生成好再绘制。等跑通了有价值的优化,我再整理一篇新的实测记录出来。
你要是按着文章把这套中心对称图案跑通了,可以把画布宽度高度、颜色方案、基础图形全换一换,看看能玩出什么花。这个项目的魅力就在这——它没有唯一正确答案,每个人都能在参数空间里找到自己的风格。动手试吧,卡住了欢迎在评论区描述你遇到的现象,最好附上当前参数组合,这样排查起来最快。
