HarmonyOS APP《画伴梦工厂》开发第12篇:涂鸦画布进阶——像素级导出与图片处理
第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;
}
关键步骤拆解:
- 文件路径生成:使用
context.filesDir获取应用沙箱目录,追加时间戳文件名,避免重复。 - 像素缓冲区分配:
new ArrayBuffer(width * height * 4)—— 每个像素 RGBA 四个字节,乘 4。 - 像素数据操作:通过
Uint8Array视图操作二进制数据。 - PixelMap 创建:
image.createPixelMap(buffer, options)将原始字节数组转换为 HarmonyOS 图像对象。 - PNG 打包:
image.createImagePacker()创建打包器,packer.packing()编码为 PNG 格式。 - 文件写入:使用
fileIo同步 API 写入。 - 资源释放:
finally块确保closeSync、release()被调用,防止内存泄漏。
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);
}
}
}
}
这里实现了一个基于距离检测的圆形绘制算法,本质上是最朴素的"中点圆算法"思路:
算法原理:
- 计算圆的外接矩形边界(left/right/top/bottom),裁剪到画布范围内,避免越界写入。
- 遍历外接矩形内的每个像素。
- 计算该像素与圆心的距离平方(
dx² + dy²),与半径平方(radiusSquare)比较。 - 若
dx² + dy² <= radiusSquare,说明该像素在圆内,填充颜色。
为什么不用 Bresenham 圆算法?Bresenham 适合描边圆(只画边界点),而我们需要实心圆,且允许笔触重叠。距离检测法虽然计算量稍大,但实现简单、正确性高,适用于涂鸦场景。
边界裁剪的重要性:centerX、centerY 通过 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
};
}
颜色解析通过位运算完成:
- 去除可选的
#前缀 - 校验长度(若不符则使用默认值
FFFFFF) parseInt(hex, 16)将六位十六进制字符串转为整数- 用移位和掩码提取 R/G/B 分量:
>> 16取高 8 位 → Red>> 8取中间 8 位 → Green& 255取低 8 位 → Blue
- 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跳转到生成等待页 - 将
imageUri和coverUri都设为导出的图片 URI try-catch兜底异常,防止应用崩溃
五、资源释放的重要性
在 exportToImage 的 finally 块中:
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 优化方向
- 增量导出:只将新增的涂鸦点绘制到已有 PixelMap 上,而非每次都重建。
- 区域检测:仅在有笔触变化的部分区域重绘。
- 使用 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支持抗锯齿边缘。
更多推荐


所有评论(0)