《HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)》第四篇:变换的魔术——平移、旋转、缩放与矩阵变换

图形变换:不仅仅是好看
HarmonyOS NEXT 开发里,图形变换是个绕不开的话题。很多人第一次接触 Canvas 时,觉得 translate、rotate、scale 就是“把图形挪一下、转一下、放大一下”,没什么技术含量。
但实际项目写多了,你会发现真正的难点不在 API 本身,而在“变换顺序”、“矩阵乘法”和“状态管理”这三件事上。
举个例子:你想让一个矩形绕它的中心旋转,而不是绕画布原点旋转。如果不理解变换坐标系,最后画出来的矩形会满屏乱飞。
这篇文章不讲虚的,直接把 translate、rotate、scale、setTransform、transform 这几个 API 拆干净,配上完整可跑的代码,重点讲清楚三个问题:变换顺序为什么那么重要、save/restore 到底在做什么、矩阵乘法怎么就和图形变换联系上了。
它解决什么问题
图形变换本质上解决的是“在画布上快速调整图形位置、姿态、大小”的问题。
| 场景 | 直接算坐标 | 使用变换 |
|---|---|---|
| 一组图形整体移动 | 需要逐个计算偏移量 | 调用一次 translate |
| 让图形绕自身中心旋转 | 需要复杂的三角函数计算 | rotate 配合 translate 即可 |
| 镜像效果(水平翻转) | 需要修改所有点的 x 坐标符号 | 一行 scale(-1, 1) |
不用变换也能画,但代码会变得极度冗长且难维护。变换的核心价值是:把位置、旋转、缩放从业务逻辑中剥离出来,让绘图代码只关心“画什么”,不关心“画在哪”。
不适合用变换的场景:
- 需要精确控制每个像素的坐标(比如像素级图像处理)
- 图形数量极少且位置固定不变
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现
三大基本变换:translate、rotate、scale
这三个 API 是变换的基石,理解它们的行为是后续一切操作的前提。
@Entry
@Component
struct BasicTransformDemo {
private canvasWidth: number = 400
private canvasHeight: number = 400
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D()
build() {
Row() {
Column() {
Canvas(this.ctx)
.width(this.canvasWidth)
.height(this.canvasHeight)
.onReady(() => {
this.drawBasicTransform()
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
/**
* 演示三种基本变换,重点观察坐标系如何变化
*/
drawBasicTransform() {
const ctx = this.ctx
// 1. 平移:把原点移到画布中心
ctx.save()
ctx.translate(200, 200)
// 2. 在平移后的坐标系画一个矩形
ctx.fillStyle = '#FF6B81'
ctx.fillRect(-50, -50, 100, 100)
// 3. 旋转45度后画第二个矩形
ctx.rotate(Math.PI / 4)
ctx.fillStyle = '#2E86C1'
ctx.fillRect(-50, -50, 100, 100)
ctx.restore()
// 4. 缩放:x方向缩小到0.5,y方向不变
ctx.save()
ctx.translate(200, 200)
ctx.scale(0.5, 1)
ctx.fillStyle = '#27AE60'
ctx.fillRect(-50, -50, 100, 100)
ctx.restore()
}
}
这段代码最值得关注的是 ctx.save() 和 ctx.restore() 的配对使用。每次 save 会把当前变换矩阵压入栈,restore 弹出,确保变换不会影响后续绘制。
注意事项:
- translate、rotate、scale 都是累加操作,不是重置。连续调用两次 translate(100, 0) 相当于 translate(200, 0)
- rotate 的单位是弧度,不是角度。常见错误:写 rotate(45) 而不是 rotate(45 * Math.PI / 180)
- scale 的参数可以是负数,负值会产生镜像效果
矩阵变换:setTransform 和 transform
如果觉得 translate/rotate/scale 不够灵活,可以直接操作变换矩阵。
@Entry
@Component
struct MatrixTransformDemo {
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D()
build() {
Row() {
Column() {
Canvas(this.ctx)
.width(400)
.height(400)
.onReady(() => {
this.drawMatrixDemo()
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
drawMatrixDemo() {
const ctx = this.ctx
// 原始矩形
ctx.save()
ctx.fillStyle = '#7B68EE'
ctx.fillRect(50, 50, 80, 80)
// 用矩阵实现水平镜像反射(相对于x=100的垂直线)
// 变换矩阵:a=1, b=0, c=0, d=-1, e=0, f=200
// 因为需要先 translate 到反射轴,再做镜像
ctx.save()
ctx.translate(100, 100) // 先移轴
ctx.scale(1, -1) // 垂直镜像
ctx.translate(-100, -100) // 移回
ctx.fillStyle = '#E91E63'
ctx.fillRect(50, 50, 80, 80)
ctx.restore()
// 使用 setTransform 一键设置矩阵
// setTransform(a, b, c, d, e, f) 不累加,直接覆盖当前矩阵
ctx.save()
// 实现绕点 (200, 200) 旋转30度
const angle = Math.PI / 6
const cx = 200, cy = 200
ctx.setTransform(
Math.cos(angle), Math.sin(angle),
-Math.sin(angle), Math.cos(angle),
cx - cx * Math.cos(angle) + cy * Math.sin(angle),
cy - cx * Math.sin(angle) - cy * Math.cos(angle)
)
ctx.fillStyle = '#3498DB'
ctx.fillRect(50, 50, 80, 80)
ctx.restore()
}
}
setTransform 与 transform 的区别:
- setTransform:直接覆盖当前变换矩阵,丢弃之前的所有变换
- transform:在现有矩阵基础上右乘新矩阵,相当于链式累加
在实际开发中,setTransform 更常用于“重置到标准状态”,transform 则用于“在现有变换基础上叠加新效果”。
为什么推荐用 setTransform 做复杂变换?
因为 translate/rotate/scale 链式调用时,中间状态的坐标系很容易让人犯晕。用矩阵可以一次性算出最终的变换参数,减少了出错概率。代价是需要手动计算矩阵参数,但理解背后的乘法规则后就不难了。
实战:鼠标交互缩放
下面这个示例演示了如何用鼠标滚轮缩放画布内容,这是很多图形编辑类应用的标配功能。
@Entry
@Component
struct ZoomableCanvas {
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D()
@State scale: number = 1.0
@State offsetX: number = 0
@State offsetY: number = 0
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.height(400)
.onReady(() => {
this.redraw()
})
.onWheel((event: WheelEvent) => {
// 鼠标滚轮缩放
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1
this.scale *= zoomFactor
this.scale = Math.max(0.1, Math.min(10, this.scale))
// 以鼠标位置为中心缩放
this.offsetX = event.offsetX - (event.offsetX - this.offsetX) * zoomFactor
this.offsetY = event.offsetY - (event.offsetY - this.offsetY) * zoomFactor
this.redraw()
})
}
}
redraw() {
const ctx = this.ctx
ctx.clearRect(0, 0, ctx.width, ctx.height)
ctx.save()
// 应用变换
ctx.translate(this.offsetX, this.offsetY)
ctx.scale(this.scale, this.scale)
// 绘制一个参考网格(实际项目中替换为业务图形)
ctx.strokeStyle = '#ddd'
ctx.lineWidth = 1
for (let x = -200; x <= 400; x += 50) {
ctx.beginPath()
ctx.moveTo(x, -200)
ctx.lineTo(x, 400)
ctx.stroke()
}
for (let y = -200; y <= 400; y += 50) {
ctx.beginPath()
ctx.moveTo(-200, y)
ctx.lineTo(400, y)
ctx.stroke()
}
// 绘制中心矩形
ctx.fillStyle = '#E74C3C'
ctx.fillRect(-50, -50, 100, 100)
ctx.restore()
}
}
注意 onWheel 事件里的 zoomFactor 计算逻辑:滚轮向上(deltaY < 0)放大,向下缩小。用 0.9 和 1.1 可以让缩放速度比较平滑,实际项目中可以根据需要调整。
这段代码的另一个关键点:缩放的中心是鼠标位置。如果直接把 scale 乘法写在 ctx 上,缩放会以画布原点为中心,而不是鼠标位置。上面的 offsetX/offsetY 修正逻辑保证了缩放中心跟随鼠标。
常见问题
坑1:变换顺序对结果的影响
现象:translate 和 rotate 的顺序不同,画出来的图形位置完全不同。
原因:变换矩阵不满足交换律。ctx.translate(x, y).rotate(angle) 和 ctx.rotate(angle).translate(x, y) 是两个不同的操作序列。
第一次写的人很容易写错顺序。记住一个原则:先执行的操作在代码中写在后面。
// 正确:先平移到中心,再旋转
ctx.translate(200, 200)
ctx.rotate(Math.PI / 4) // 这里旋转的是平移后的坐标系
// 错误:先旋转,再平移。旋转后坐标系方向变了,平移的方向也跟着变
ctx.rotate(Math.PI / 4)
ctx.translate(200, 200)
解法:写变换代码之前,先在纸上画出坐标系变化过程。或者用下面这种更清晰的结构:
// 先思考"我想先做什么",然后从最后一个操作开始写
function drawRotatedRect(x: number, y: number, angle: number) {
ctx.save()
ctx.translate(x, y) // 第一步:移到目标位置
ctx.rotate(angle) // 第二步:旋转
ctx.fillRect(-50, -50, 100, 100) // 第三步:以当前坐标系原点为中心画矩形
ctx.restore()
}
坑2:save/restore 配对遗漏导致变换污染
现象:一个组件画完后,后面再画的内容位置、旋转全部不对。
原因:save 和 restore 没有成对出现。变换状态会一直保留在上下文中,影响后续所有绘制。
这个问题在复杂页面里特别隐蔽,尤其是循环绘制时。比如 for 循环里面画了 10 个图形,其中一个忘了 restore 或者写了一个多余的 restore,后面的 9 个全歪。
解法:养成每 save 必 restore 的习惯。如果保存了一次,就必须恢复一次。可以用 try/finally 结构保底:
ctx.save()
try {
ctx.translate(x, y)
ctx.rotate(angle)
ctx.fillRect(-25, -25, 50, 50)
} finally {
ctx.restore()
}
try/finally 不会影响性能,但能防止异常中途跳出导致 restore 不执行。
坑3:矩阵乘法顺序与预期相反
现象:手动计算旋转矩阵后,图形没有按期望的角度旋转。
原因:Canvas 的矩阵乘法是右乘。也就是说,ctx.setTransform(A * B) 的效果是 “先应用 B,再应用 A”。
这个和数学上的矩阵左乘习惯刚好相反,很多人第一次写时都会中招。
解法:记住矩阵堆叠顺序:最后调用的变换,最先应用。
// 想要的效果:先平移(T),再旋转(R),再缩放(S)
// 矩阵顺序(从右到左):S * R * T
// 代码顺序(从上到下):translate -> rotate -> scale
ctx.translate(200, 200) // T
ctx.rotate(Math.PI / 4) // R
ctx.scale(2, 2) // S
如果直接用 setTransform,需要自己按 S * R * T 的顺序相乘。
最佳实践
-
不要把变换写在 build() 里。build() 是声明式 UI 的构建函数,频繁调用会导致性能问题。变换逻辑应该放在 onReady 或者 @State 变化对应的回调里。
-
推荐用 @State 管理变换参数。把 scale、offsetX、offsetY 声明为 @State,配合 @Watch 或 @Observed,当变换参数变化时自动触发重绘,代码更干净。
-
复杂的变换动画用 animateTo。不要手动写 requestAnimationFrame 控制变换参数。HarmonyOS 的 animateTo 能处理补间动画,且与 ArkUI 的渲染流水线耦合更好,动画更流畅。
-
测试变换时,加上内网格和坐标系辅助线。很多变换问题肉眼难发现,画上网格后,旋转和缩放的偏差一眼就能看出来。
-
尽量让变换原点与绘制原点一致。比如绕矩形中心旋转,应该先 translate 到中心,再 rotate。如果绕原点旋转后还要再平移,会引入额外的偏差计算,容易出错。
FAQ
Q:为什么真机运行正常,但模拟器上 save/restore 不起作用?
A:模拟器对 CanvasRenderingContext2D 的状态管理实现与真机存在差异。如果遇到这种情况,检查 save 和 restore 是否在同一个同步代码块中。模拟器中跨异步任务的 save/restore 可能会失效。建议所有 save/restore 操作在同一个 onReady 回调的同步代码中完成。
Q:为什么页面返回后,之前保存的变换状态丢失了?
A:页面返回时,Canvas 组件会被销毁重新创建。如果变换状态存储在组件内部(没有持久化),重新创建后状态自然消失。解决方法:把变换参数存储在 ViewModel 或 AppStorage 中,在 onReady 里恢复。
Q:为什么用 @State 控制 scale 时,动画会卡顿?
A:@State 驱动的 setter 会触发整个组件重绘,而不仅仅是 Canvas 内容。如果画布内容复杂,可以考虑把 canvas 的绘制逻辑抽到单独的子组件中,用 @Link 或者 @Prop 传参,避免父组件重建。
Q:setTransform 和 resetTransform 有什么区别?
A:setTransform(a, b, c, d, e, f) 直接把当前变换矩阵设置成指定值。resetTransform() 等价于 setTransform(1, 0, 0, 1, 0, 0),也就是恢复到单位矩阵(无变换)。如果你的需求是“清除所有变换,回到初始状态”,用 resetTransform 更明确。如果要跳到某个特定变换,用 setTransform。
示例代码地址:项目地址
更多推荐

所有评论(0)