先看真机里的连续构图:照片在票根裁剪区内横向、纵向移动,随后逐步放大,外层邮票、边框和装饰始终保持原位。

这段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
SDKAPI 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和 ImageSourcefinally 中关闭。日志只记录源图和解码尺寸,不记录相册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);
}

drawWidthdrawHeight 按“铺满裁剪区”的方式计算,初始状态不会出现黑边。把滑杆推到极端位置时可以故意看到照片离开裁剪区;点击“复位构图”后,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)

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐