第2.4篇:涂鸦画布进阶——像素级导出与图片处理

难度:⭐⭐⭐ 高级 | 前置知识:2.3 Canvas 自由涂鸦 | 涉及源文件products/default/src/main/ets/components/CreationComponents.ets


在这里插入图片描述

一、引言

在 2.3 节中,我们使用 Circle 组件 实现了自由涂鸦画布,用户的手指轨迹被记录为一个个点(DoodlePoint),并实时渲染为圆形笔触。然而,要让这些涂鸦真正"走出画布"——导出为 PNG 图片、传递给 AI 生成动画——我们需要一套纯代码级别的像素级导出方案。

HarmonyOS 的 Canvas 组件虽然提供了 CanvasRenderingContext2D 标准绘制 API,但在某些场景下——例如本项目的"非 Canvas 纯 ArkUI 组件方案"——我们并没有使用传统的 Canvas 绘制,而是用 Circle 组件堆叠出涂鸦效果。这意味着不能直接调用 Canvas.toDataURL() 来导出。

解决方案:手动构建像素数据(ArrayBuffer),使用 @kit.ImageKit 创建像素图,再通过 ImagePacker 打包为 PNG 文件。

本文将深入剖析这一过程的每个环节。


二、导出流程总览

FreeDoodleComponent 的导出流程如下:

用户点击"用涂鸦生成" → useDoodle()
  → exportToImage()
    → 创建 ArrayBuffer(720 × canvasHeight × 4)
    → 填充背景色(fillBackground)
    → 遍历所有 DoodlePoint,绘制圆形笔触(drawCircle)
    → 创建 PixelMap(image.createPixelMap)
    → 使用 ImagePacker 打包为 PNG 字节流(packer.packing)
    → 写入文件(fileIo.writeSync)
    → 释放资源(pixelMap.release / packer.release)
    → 返回文件 URI
  → Router 跳转到 RecognitionWaitingPage

三、核心方法详解

3.1 exportToImage:导出入口

private async exportToImage(): Promise<string> {
  const context = getContext(this) as common.UIAbilityContext;
  const path = context.filesDir + '/doodle_' + Date.now().toString() + '.png';
  const width = 720;
  const height = this.canvasHeight;
  const buffer = new ArrayBuffer(width * height * 4);
  const pixels = new Uint8Array(buffer);
  this.fillBackground(pixels, width, height, this.parseHexColor(this.canvasBackground));
  this.points.forEach((point: DoodlePoint) => {
    this.drawCircle(pixels, width, height, point);
  });
  const pixelMap = await image.createPixelMap(buffer, {
    size: { width: width, height: height },
    pixelFormat: image.PixelMapFormat.RGBA_8888,
    editable: true
  });
  const packer = image.createImagePacker();
  const packedBuffer = await packer.packing(pixelMap, {
    format: 'image/png',
    quality: 100
  });
  const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
  try {
    fileIo.writeSync(file.fd, packedBuffer);
  } finally {
    fileIo.closeSync(file);
    pixelMap.release();
    packer.release();
  }
  return 'file://' + path;
}

关键步骤拆解

  1. 文件路径生成:使用 context.filesDir 获取应用沙箱目录,追加时间戳文件名,避免重复。
  2. 像素缓冲区分配new ArrayBuffer(width * height * 4) —— 每个像素 RGBA 四个字节,乘 4。
  3. 像素数据操作:通过 Uint8Array 视图操作二进制数据。
  4. PixelMap 创建image.createPixelMap(buffer, options) 将原始字节数组转换为 HarmonyOS 图像对象。
  5. PNG 打包image.createImagePacker() 创建打包器,packer.packing() 编码为 PNG 格式。
  6. 文件写入:使用 fileIo 同步 API 写入。
  7. 资源释放finally 块确保 closeSyncrelease() 被调用,防止内存泄漏。

3.2 fillBackground:背景填充

private fillBackground(pixels: Uint8Array, width: number, height: number, color: RgbaColor): void {
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      this.writePixel(pixels, (y * width + x) * 4, color);
    }
  }
}

这是一个双层循环扫描线算法

  • 外层循环遍历每一行(y 轴)
  • 内层循环遍历每一列(x 轴)
  • (y * width + x) * 4 计算出像素在 ArrayBuffer 中的偏移量
  • writePixel 将 RGBA 四字节写入对应位置

算法复杂度为 O(width × height)。对于 720×360 的画布,需要写入约 100 万个像素,在 ArkTS 中性能可以接受。

3.3 drawCircle:像素级圆形绘制

private drawCircle(pixels: Uint8Array, width: number, height: number, point: DoodlePoint): void {
  const color = this.parseHexColor(point.color);
  const radius = Math.max(1, Math.round(point.size / 2));
  const centerX = Math.max(0, Math.min(width - 1, Math.round(point.x)));
  const centerY = Math.max(0, Math.min(height - 1, Math.round(point.y)));
  const left = Math.max(0, centerX - radius);
  const right = Math.min(width - 1, centerX + radius);
  const top = Math.max(0, centerY - radius);
  const bottom = Math.min(height - 1, centerY + radius);
  const radiusSquare = radius * radius;
  for (let y = top; y <= bottom; y++) {
    for (let x = left; x <= right; x++) {
      const dx = x - centerX;
      const dy = y - centerY;
      if (dx * dx + dy * dy <= radiusSquare) {
        this.writePixel(pixels, (y * width + x) * 4, color);
      }
    }
  }
}

这里实现了一个基于距离检测的圆形绘制算法,本质上是最朴素的"中点圆算法"思路:

算法原理

  1. 计算圆的外接矩形边界(left/right/top/bottom),裁剪到画布范围内,避免越界写入。
  2. 遍历外接矩形内的每个像素。
  3. 计算该像素与圆心的距离平方(dx² + dy²),与半径平方(radiusSquare)比较。
  4. dx² + dy² <= radiusSquare,说明该像素在圆内,填充颜色。

为什么不用 Bresenham 圆算法?Bresenham 适合描边圆(只画边界点),而我们需要实心圆,且允许笔触重叠。距离检测法虽然计算量稍大,但实现简单、正确性高,适用于涂鸦场景。

边界裁剪的重要性:centerXcenterY 通过 Math.max(0, Math.min(...)) 确保圆心在画布内;外接矩形边界同样做了裁剪,防止 Uint8Array 越界访问导致崩溃。

3.4 parseHexColor:十六进制颜色解析

private parseHexColor(hex: string): RgbaColor {
  const normalized = hex.startsWith('#') ? hex.substring(1) : hex;
  const value = Number.parseInt(normalized.length === 6 ? normalized : 'FFFFFF', 16);
  return {
    red: (value >> 16) & 255,
    green: (value >> 8) & 255,
    blue: value & 255,
    alpha: 255
  };
}

颜色解析通过位运算完成:

  1. 去除可选的 # 前缀
  2. 校验长度(若不符则使用默认值 FFFFFF
  3. parseInt(hex, 16) 将六位十六进制字符串转为整数
  4. 用移位和掩码提取 R/G/B 分量:
    • >> 16 取高 8 位 → Red
    • >> 8 取中间 8 位 → Green
    • & 255 取低 8 位 → Blue
  5. Alpha 固定为 255(完全不透明)

3.5 writePixel:单像素写入

private writePixel(pixels: Uint8Array, offset: number, color: RgbaColor): void {
  pixels[offset] = color.red;
  pixels[offset + 1] = color.green;
  pixels[offset + 2] = color.blue;
  pixels[offset + 3] = color.alpha;
}

这是最底层的写入函数,直接操作 ArrayBuffer。RGBA 8888 格式的排列顺序为 Red → Green → Blue → Alpha,每个通道 8 位(0-255)。


四、导出后跳转:useDoodle 方法

private async useDoodle() {
  if (this.points.length === 0) {
    this.noticeText = '请先在画布上涂鸦';
    return;
  }
  try {
    this.generationProgress = 92;
    this.noticeText = '正在导出涂鸦图片';
    const imageUri = await this.exportToImage();
    this.generationProgress = 100;
    this.noticeText = '自由涂鸦已导出,即将生成动画';
    this.getUIContext().getRouter().pushUrl({
      url: 'pages/RecognitionWaitingPage',
      params: {
        source: '自由涂鸦',
        workSource: 'doodle',
        prompt: '把这张儿童涂鸦变成温暖的短动画,保留粗笔触和明亮色块',
        imageUri: imageUri,
        coverUri: imageUri
      }
    });
  } catch (error) {
    this.noticeText = '涂鸦导出失败,请重试';
  }
}

逻辑要点

  • 导出前检查涂鸦是否为空
  • 通过 @Link generationProgress 通知父组件进度变更
  • 导出成功后通过 Router.pushUrl 跳转到生成等待页
  • imageUricoverUri 都设为导出的图片 URI
  • try-catch 兜底异常,防止应用崩溃

五、资源释放的重要性

exportToImagefinally 块中:

fileIo.closeSync(file);
pixelMap.release();
packer.release();

这三个释放操作缺一不可

资源 释放方式 后果若不释放
文件描述符 fileIo.closeSync 文件句柄泄漏,多次导出后无法写入
PixelMap pixelMap.release() 图像缓冲区内存泄漏
ImagePacker packer.release() 编码器资源泄漏

HarmonyOS 的 @kit.ImageKit 采用了引用计数式资源管理,手动调用 release() 是防止内存泄漏的关键。


六、性能优化思考

6.1 当前方案的局限

  • 全像素遍历:每次导出遍历所有像素(720×360 ≈ 26 万像素),涂鸦点数越多,drawCircle 的绘制量也越大。
  • ArrayBuffer 分配:每次导出都 new ArrayBuffer,产生 GC 压力。

6.2 优化方向

  1. 增量导出:只将新增的涂鸦点绘制到已有 PixelMap 上,而非每次都重建。
  2. 区域检测:仅在有笔触变化的部分区域重绘。
  3. 使用 Canvas 替代:如果改用 Canvas API,可利用 GPU 加速渲染和导出。

不过对于儿童涂鸦应用而言,单次导出耗时通常在毫秒级,当前方案足够实用


七、总结

本文介绍了 HarmonyOS 上不使用 Canvas API、而通过 ArrayBuffer + PixelMap + ImagePacker 实现像素级图片导出的完整方案:

环节 关键技术 对应 API
像素缓冲区 ArrayBuffer + Uint8Array new ArrayBuffer(size)
背景填充 双层扫描线 fillBackground()
圆形绘制 距离检测法(中点圆算法) drawCircle()
颜色解析 位运算提取 RGB parseHexColor()
创建像素图 Raw 数据转 PixelMap image.createPixelMap()
PNG 编码 ImagePacker 打包 image.createImagePacker()
文件写入 沙箱目录 + fileIo fileIo.writeSync()
资源释放 手动释放 .release()
页面跳转 Router router.pushUrl()

这套方案的核心价值在于:当你使用非 Canvas 的 ArkUI 组件构建图形界面时,仍然可以通过手动构建像素数据来实现图片导出。这不仅适用于涂鸦应用,也适用于任何需要将 UI 内容转为位图文件的场景。


动手挑战:尝试在 exportToImage 中加入透明度支持(当前 Alpha 固定 255),或扩展 drawCircle 支持抗锯齿边缘。

Logo

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

更多推荐