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() → 创建 ArrayBuffer720 × canvasHeight × 4 → 填充背景色fillBackground → 遍历所有 DoodlePoint绘制圆形笔触drawCircle → 创建 PixelMapimage.createPixelMap → 使用 ImagePacker 打包为 PNG 字节流packer.packing → 写入文件fileIo.writeSync → 释放资源pixelMap.release / packer.release → 返回文件 URI → Router 跳转到 RecognitionWaitingPage三、核心方法详解3.1 exportToImage导出入口privateasyncexportToImage():Promisestring{constcontextgetContext(this)ascommon.UIAbilityContext;constpathcontext.filesDir/doodle_Date.now().toString().png;constwidth720;constheightthis.canvasHeight;constbuffernewArrayBuffer(width*height*4);constpixelsnewUint8Array(buffer);this.fillBackground(pixels,width,height,this.parseHexColor(this.canvasBackground));this.points.forEach((point:DoodlePoint){this.drawCircle(pixels,width,height,point);});constpixelMapawaitimage.createPixelMap(buffer,{size:{width:width,height:height},pixelFormat:image.PixelMapFormat.RGBA_8888,editable:true});constpackerimage.createImagePacker();constpackedBufferawaitpacker.packing(pixelMap,{format:image/png,quality:100});constfilefileIo.openSync(path,fileIo.OpenMode.CREATE|fileIo.OpenMode.READ_WRITE);try{fileIo.writeSync(file.fd,packedBuffer);}finally{fileIo.closeSync(file);pixelMap.release();packer.release();}returnfile://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背景填充privatefillBackground(pixels:Uint8Array,width:number,height:number,color:RgbaColor):void{for(lety0;yheight;y){for(letx0;xwidth;x){this.writePixel(pixels,(y*widthx)*4,color);}}}这是一个双层循环扫描线算法外层循环遍历每一行y 轴内层循环遍历每一列x 轴(y * width x) * 4计算出像素在 ArrayBuffer 中的偏移量writePixel将 RGBA 四字节写入对应位置算法复杂度为 O(width × height)。对于 720×360 的画布需要写入约 100 万个像素在 ArkTS 中性能可以接受。3.3 drawCircle像素级圆形绘制privatedrawCircle(pixels:Uint8Array,width:number,height:number,point:DoodlePoint):void{constcolorthis.parseHexColor(point.color);constradiusMath.max(1,Math.round(point.size/2));constcenterXMath.max(0,Math.min(width-1,Math.round(point.x)));constcenterYMath.max(0,Math.min(height-1,Math.round(point.y)));constleftMath.max(0,centerX-radius);constrightMath.min(width-1,centerXradius);consttopMath.max(0,centerY-radius);constbottomMath.min(height-1,centerYradius);constradiusSquareradius*radius;for(letytop;ybottom;y){for(letxleft;xright;x){constdxx-centerX;constdyy-centerY;if(dx*dxdy*dyradiusSquare){this.writePixel(pixels,(y*widthx)*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十六进制颜色解析privateparseHexColor(hex:string):RgbaColor{constnormalizedhex.startsWith(#)?hex.substring(1):hex;constvalueNumber.parseInt(normalized.length6?normalized:FFFFFF,16);return{red:(value16)255,green:(value8)255,blue:value255,alpha:255};}颜色解析通过位运算完成去除可选的#前缀校验长度若不符则使用默认值FFFFFFparseInt(hex, 16)将六位十六进制字符串转为整数用移位和掩码提取 R/G/B 分量 16取高 8 位 → Red 8取中间 8 位 → Green 255取低 8 位 → BlueAlpha 固定为 255完全不透明3.5 writePixel单像素写入privatewritePixel(pixels:Uint8Array,offset:number,color:RgbaColor):void{pixels[offset]color.red;pixels[offset1]color.green;pixels[offset2]color.blue;pixels[offset3]color.alpha;}这是最底层的写入函数直接操作 ArrayBuffer。RGBA 8888 格式的排列顺序为 Red → Green → Blue → Alpha每个通道 8 位0-255。四、导出后跳转useDoodle 方法privateasyncuseDoodle(){if(this.points.length0){this.noticeText请先在画布上涂鸦;return;}try{this.generationProgress92;this.noticeText正在导出涂鸦图片;constimageUriawaitthis.exportToImage();this.generationProgress100;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都设为导出的图片 URItry-catch兜底异常防止应用崩溃五、资源释放的重要性在exportToImage的finally块中fileIo.closeSync(file);pixelMap.release();packer.release();这三个释放操作缺一不可资源释放方式后果若不释放文件描述符fileIo.closeSync文件句柄泄漏多次导出后无法写入PixelMappixelMap.release()图像缓冲区内存泄漏ImagePackerpacker.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 Uint8Arraynew ArrayBuffer(size)背景填充双层扫描线fillBackground()圆形绘制距离检测法中点圆算法drawCircle()颜色解析位运算提取 RGBparseHexColor()创建像素图Raw 数据转 PixelMapimage.createPixelMap()PNG 编码ImagePacker 打包image.createImagePacker()文件写入沙箱目录 fileIofileIo.writeSync()资源释放手动释放.release()页面跳转Routerrouter.pushUrl()这套方案的核心价值在于当你使用非 Canvas 的 ArkUI 组件构建图形界面时仍然可以通过手动构建像素数据来实现图片导出。这不仅适用于涂鸦应用也适用于任何需要将 UI 内容转为位图文件的场景。动手挑战尝试在exportToImage中加入透明度支持当前 Alpha 固定 255或扩展drawCircle支持抗锯齿边缘。