HarmonyOS ArkGraphics 2D Canvas状态栈实操——做一个可裁剪旋转的旅拍明信片编辑器
先看真机里的连续构图:照片在票根裁剪区内横向、纵向移动,随后逐步放大,外层邮票、边框和装饰始终保持原位。
这段GIF来自真实照片和真机连续操作,不是生成图,也不是两张静态图片来回切换。
这次做的是一个可以直接看懂用途的Canvas案例:从系统相册选择一张旅拍照片,把它放进圆角、拱门或票根形状的明信片里,再调整照片的位置、缩放和旋转角度,最后生成一张冻结的成品预览。
页面里的照片、边框、邮票、装饰线和安全区不是一张合成背景图,而是 ArkGraphics 2D Canvas 分层绘制的。每个图层都独立执行 save → clip/transform/draw → restore,这样照片的旋转不会带歪邮票,裁剪区域也不会影响后面的装饰。
状态栈的工作顺序可以直接看成四步:保存当前状态,裁剪照片层,只变换照片,最后恢复到进入前的Canvas状态。
本次结果
| 操作 | Canvas处理 | 真机结果 |
|---|---|---|
| 选择照片 | 系统 Photo Picker返回URI,只读fd解码为PixelMap | 载入1056 × 1584 px真实照片,没有申请存储权限 |
| 切换模板 | 重建圆角、拱门、票根三种Path并执行clipPath | 三种轮廓都完成真实裁剪,不是只换外框 |
| 调整构图 | translate、rotate、scale后drawImageRect | 照片在裁剪区内实时移动、缩放和旋转 |
| 开关安全区 | 安全区使用独立save/clip/restore | 关闭后参考框消失,照片和边框不受影响 |
| 生成成品 | 创建RGBA PixelMap和离屏drawing.Canvas | 得到1172 × 856 px冻结预览 |
| 返回与复位 | 释放预览PixelMap,重绘编辑画布 | 回到EDIT,复位后X/Y/旋转归零、缩放回到1.00 |
| 退出页面 | 释放照片、预览和RenderNode | 日志确认photoReleased=true、balanced=true |
实验准备
| 项目 | 实际环境 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 开发方式 | ArkTS / ArkUI |
| 图形接口 | @kit.ArkGraphics2D |
| 图像接口 | @kit.ImageKit |
| 相册接口 | @kit.MediaLibraryKit |
| 运行权限 | 不需要 |
本次准备一张竖向旅拍照片即可。为了避免大图直接解码带来的内存压力,代码把编辑副本的长边限制在1600 px;本次照片本身小于这个值,所以编辑副本仍为1056 × 1584 px。
第一步:先把Canvas状态栈守住
这个实验的关键不是先画多少图形,而是确保每次绘制离开时,Canvas仍回到进入时的状态。
draw() 开始时记录 getSaveCount(),绘制结束后再检查一次。如果中间某个图层忘记恢复,finally 会把状态栈拉回入口深度:
draw(context: DrawContext): void {
const width: number = Math.max(
1,
Math.round(context.sizeInPixel.width)
);
const height: number = Math.max(
1,
Math.round(context.sizeInPixel.height)
);
const canvas: drawing.Canvas = context.canvas;
this.entryDepthValue = canvas.getSaveCount();
try {
if (this.preview) {
canvas.clear(0xFF091424);
canvas.drawImage(this.preview, 0, 0);
} else {
this.drawPostcardScene(
canvas,
width,
height,
this.stateValue,
this.photo,
this.photoWidth,
this.photoHeight,
true
);
}
} finally {
const currentDepth: number = canvas.getSaveCount();
if (currentDepth !== this.entryDepthValue) {
canvas.restoreToCount(this.entryDepthValue);
}
this.exitDepthValue = canvas.getSaveCount();
this.balancedValue =
this.entryDepthValue === this.exitDepthValue;
}
}
这里保存的是Canvas当前的裁剪和变换状态,不是保存一张图片。我们在真机页面直接显示入口深度、出口深度和 BALANCED,比只看画面更容易发现状态泄漏。
真机载入照片后,圆角模板正常显示,页面右上角和证据卡都显示状态平衡:
第二步:从系统相册读取照片
照片选择使用系统 Photo Picker。应用只拿到用户本次选择的内容,不需要申请读取整个相册的权限。
const options: photoAccessHelper.PhotoSelectOptions =
new photoAccessHelper.PhotoSelectOptions();
options.MIMEType =
photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
options.isOriginalSupported = true;
const picker: photoAccessHelper.PhotoViewPicker =
new photoAccessHelper.PhotoViewPicker();
const result: photoAccessHelper.PhotoSelectResult =
await picker.select(options);
if (result.photoUris.length > 0) {
await this.decodePhoto(result.photoUris[0]);
}
拿到URI后用只读fd创建 ImageSource,按源图比例计算编辑副本大小,再解码成 RGBA_8888 PixelMap:
const selectedFile: fileIo.File =
fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const selectedFd: number = selectedFile.fd;
const imageSource: image.ImageSource =
image.createImageSource(selectedFd);
const info: image.ImageInfo = await imageSource.getImageInfo();
const longEdge: number = Math.max(
info.size.width,
info.size.height
);
const scale: number = longEdge > 1600
? 1600 / longEdge
: 1;
const pixelMap: image.PixelMap =
await imageSource.createPixelMap({
desiredSize: {
width: Math.round(info.size.width * scale),
height: Math.round(info.size.height * scale)
},
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
editable: false
});
fd和 ImageSource 在 finally 中关闭。日志只记录源图和解码尺寸,不记录相册URI。
第三步:用三条Path做真正的模板裁剪
三个模板共用同一张照片和同一套变换参数,区别只在裁剪Path。
private templatePath(
left: number,
top: number,
right: number,
bottom: number,
templateIndex: number,
width: number
): drawing.Path {
const path: drawing.Path = new drawing.Path();
if (templateIndex === 0) {
const roundRect: drawing.RoundRect =
new drawing.RoundRect(
{
left: left,
top: top,
right: right,
bottom: bottom
},
width * 0.035,
width * 0.035
);
path.addRoundRect(
roundRect,
drawing.PathDirection.CLOCKWISE
);
} else if (templateIndex === 1) {
const archHeight: number =
(bottom - top) * 0.42;
path.moveTo(left, bottom);
path.lineTo(left, top + archHeight);
path.cubicTo(
left,
top - archHeight * 0.18,
right,
top - archHeight * 0.18,
right,
top + archHeight
);
path.lineTo(right, bottom);
path.close();
} else {
const cut: number = width * 0.035;
path.moveTo(left + cut, top);
path.lineTo(right - cut, top);
path.lineTo(right, top + cut);
path.lineTo(right, bottom - cut);
path.lineTo(right - cut, bottom);
path.lineTo(left + cut, bottom);
path.lineTo(left, bottom - cut);
path.lineTo(left, top + cut);
path.close();
}
return path;
}
切到拱门模板后,照片顶部被Path的三次贝塞尔曲线裁成拱形:
票根模板使用八段折线切掉四角,照片边缘也跟随裁剪:
第四步:在裁剪区内移动、缩放和旋转
照片层进入后先保存状态、应用裁剪,再把坐标原点移到模板中心。照片的平移、旋转和缩放都只发生在这一层,finally 负责回到进入前的深度。
const entryDepth: number = canvas.getSaveCount();
canvas.save();
try {
canvas.clipPath(
clipPath,
drawing.ClipOp.INTERSECT,
true
);
const centerX: number =
(left + right) * 0.5 + state.offsetX * width * 0.0032;
const centerY: number =
(top + bottom) * 0.5 + state.offsetY * height * 0.0032;
canvas.translate(centerX, centerY);
canvas.rotate(state.rotation, 0, 0);
canvas.scale(state.zoom, state.zoom);
canvas.drawImageRect(photo, {
left: -drawWidth * 0.5,
top: -drawHeight * 0.5,
right: drawWidth * 0.5,
bottom: drawHeight * 0.5
});
} finally {
canvas.restoreToCount(entryDepth);
}
drawWidth 和 drawHeight 按“铺满裁剪区”的方式计算,初始状态不会出现黑边。把滑杆推到极端位置时可以故意看到照片离开裁剪区;点击“复位构图”后,X、Y、旋转回到0,缩放回到1.00。
真机把照片调整到 X 16、Y -40、缩放1.05、旋转16° 后,照片已经倾斜,但邮票、票根边框和底部装饰仍保持原位,状态栈仍为 2 → 2:
第五步:让安全区成为独立图层
安全区只用于编辑时观察构图,不应该改变照片裁剪,也不应该进入成品图。它使用自己的保存点:
const entryDepth: number = canvas.getSaveCount();
canvas.save();
try {
canvas.clipPath(
clipPath,
drawing.ClipOp.INTERSECT,
true
);
// 绘制安全区圆角框、四角锚点
} finally {
canvas.restoreToCount(entryDepth);
}
关闭开关后,页面仍处于 EDIT,照片、模板和变换参数都没有重置:
第六步:生成一张离屏成品预览
成品预览不是截取手机屏幕。代码按当前直接画布的像素尺寸创建一张可编辑PixelMap,再用它构造离屏Canvas,最后调用与实时编辑相同的 drawPostcardScene()。
const metrics: PostcardMetrics =
this.postcardNode.getMetrics();
const pixelBuffer: ArrayBuffer = new ArrayBuffer(
metrics.width * metrics.height * 4
);
const pixelMap: image.PixelMap =
await image.createPixelMap(pixelBuffer, {
editable: true,
pixelFormat: image.PixelMapFormat.RGBA_8888,
size: {
width: metrics.width,
height: metrics.height
}
});
const offscreenCanvas: drawing.Canvas =
new drawing.Canvas(pixelMap);
this.postcardNode.drawPostcardScene(
offscreenCanvas,
metrics.width,
metrics.height,
this.postcardNode.getState(),
this.postcardNode.getPhoto(),
this.postcardNode.getPhotoWidth(),
this.postcardNode.getPhotoHeight(),
false
);
this.postcardNode.setPreview(pixelMap);
最后一个参数传入 false,成品中不会绘制编辑安全区。真机生成第1张预览后,页面切换到 PREVIEW,尺寸为1172 × 856 px:
等待2秒后,我们再次截取相同画布区域。去掉会变化的系统状态栏后,两次画布区域的SHA-256完全相同:
40ba9b118c14bf609f11f476937bf9440c78ed1fce71ce8d12a554b3fab577e6
返回编辑时先释放预览PixelMap,再调用 invalidate() 重绘实时Canvas;照片和变换参数仍保留。随后点击复位,页面恢复到初始构图:
实操中遇到的三个情况
1. 照片已经载入,右侧仍显示“演示占位图”
第一版把照片来源、模板名称和滑杆数值作为普通参数传给 @Builder。Canvas已经更新,但这些文字保留了首次构建值。
处理方式是让动态 Text 直接读取组件的 @State。重新编译和安装后,照片来源变成“真实相册照片”,模板名称和滑杆数值也会同步变化。正式截图全部在修复后重新采集。
2. 极端平移会露出明信片底纸
这是本实验保留的可观察边界:滑杆范围允许把照片推到裁剪区边缘,用来确认 clipPath 和变换顺序确实生效。需要恢复正常构图时点击“复位构图”,或者先提高缩放值再移动照片。
3. 应用进入后台后,照片需要重新选择
页面消失时会主动释放PixelMap和RenderNode,防止相册大图长期占用内存。因此应用从后台重新进入实验页时,变换数值可以保留,但照片副本需要重新选择。正式成品预览在照片重新载入后完成。
最终真机日志
下面直接保留最终交互链路,应用标识和相册URI已经删除:
POSTCARD_PICK success=true sourceWidth=1056 sourceHeight=1584 decodeWidth=1056 decodeHeight=1584 uriLogged=false
POSTCARD_SAFE_AREA visible=false
POSTCARD_PREVIEW success=true number=1 width=1172 height=856 entryDepth=2 exitDepth=2
POSTCARD_EDIT_RESUME previewNumber=1
POSTCARD_RESET template=2 photoLoaded=true
POSTCARD_RELEASE action=EXIT drawCount=247 previewNumber=1 entryDepth=2 exitDepth=2 balanced=true photoReleased=true
最后结果
这次实验把Canvas状态栈放进了一个完整使用场景里:真实照片由系统Picker进入应用,三种Path完成模板裁剪,照片层独立执行平移、缩放和旋转,安全区、边框、邮票和装饰各自保存并恢复状态,最后再用离屏Canvas生成冻结成品。
真机最终确认:三种模板都能切换,连续变换即时生效,安全区可独立关闭,离屏预览成功生成,返回编辑和复位正常,退出时照片与预览资源释放;整个交互过程中入口和出口深度始终相同。
官方资料:画布操作状态(ArkTS)
更多推荐

所有评论(0)