我在开发鸿蒙图片编辑器时,实现像素马赛克功能遇到了两大噩梦:绘制卡顿和图片色调异常。经过多次重构,最终找到了一套既简单又高效的方案。本文将完整复盘踩坑过程与解决方案,希望能帮你少走弯路。
完整源码:ImageEditor
一、需求与初步尝试
图片编辑器中,马赛克画笔常用于遮盖人脸、车牌、文字等敏感信息。常见的实现方式有两种:
- 纹理马赛克:用预设的纹理图片(如小方格图片)重复填充涂抹区域。
- 像素马赛克:将涂抹区域划分为一个个方块,每个方块的颜色取原图该方块内所有像素的平均色。
纹理马赛克性能极佳,但效果像贴了一层“瓷砖”,不够自然。因此我选择了更真实的像素马赛克。
1.1 初版思路(错误示范)
大致流程:
- 用户在 Canvas 上涂抹,记录所有矩形的坐标。
- 每次涂抹结束,将所有矩形和原图传入
TaskPool,在后台线程中逐矩形计算平均色,修改整张图片的像素。 - 将修改后的图片重新绘制到 Canvas 上;撤销时重新执行所有历史矩形。
这种思路在逻辑上说得通,但实际运行时卡成 ppt,而且因为直接操作像素缓冲区,出现了未知的色调偏移(红变蓝、整体偏色)。
二、问题根源分析
2.1 卡顿:每次修改整张图片,而非增量绘制
初版在每笔涂抹结束后,都对整张图片的所有矩形重新做一遍像素处理。随着矩形数量增加(一次快速涂抹可能产生上百个矩形),处理时间线性增长。用户手指一抬,要等几百毫秒才能看到马赛克,体验极差。
2.2 色调异常:直接修改像素极易出错
从readPixelsToBuffer获取的原始像素数据,其内存布局、颜色空间、Alpha 通道的处理等细节与预期可能存在差异。多次调整通道顺序仍无法完全消除色偏。这促使我寻找完全不修改原图的替代方案——既然马赛克只是“遮挡”,何必改变底图呢?
2.3 其他问题
- 没有插值:手指滑动过快时,触摸事件采样点间隔较大,导致马赛克块之间出现缝隙。
- 重复计算:历史重绘时又重新扫描像素,浪费 CPU。
三、解决方案:放弃修改原图,采用实时绘制 + 颜色缓存
核心思想:不再修改原图,而是将马赛克视为独立的绘图指令,直接在 Canvas 上层绘制矩形。每个矩形在生成时就计算好颜色并缓存,重绘时直接使用缓存的颜色,无需再触碰像素数据。
这样做的好处:
- 原图永远不变,彻底避免像素操作带来的色偏风险。
- 绘制速度只与矩形数量有关,与图片尺寸无关。
- 撤销/重绘仅需重新执行绘图指令,极快。
3.1 运行效果
3.2 整体架构
用户涂抹 → 生成矩形 → 立即取中心点颜色(同步)→ 绘制到 Canvas(实时预览) ↓ 涂抹结束 → 保存矩形+颜色 → 入命令栈 ↓ 撤销/重绘 → 直接使用保存的颜色重新绘制3.3 数据结构
// 带颜色的马赛克矩形exportinterfaceMosaicRect{rect:DrawRect;// {x, y, width, height} 图像坐标系color:string;// "rgb(r,g,b)"}// 历史命令exportinterfacePixelMosaicCommand{id:string;type:'pixelMosaic';rects:MosaicRect[];blockSize:number;}3.4 像素马赛克管理器(核心代码)
// PixelMosaicManager.etsimport{DrawRect}from'../model/CanvasInfo';exportclassPixelMosaicManager{privatecurrentRects:MosaicRect[]=[];privatebrushSize=20;privaterawPixels:Uint8ClampedArray|null=null;privateimageWidth=0,imageHeight=0;privatelastX=0,lastY=0,hasLast=false;// 设置原始像素数据(仅用于取色,不修改)setRawPixels(pixels:Uint8ClampedArray,width:number,height:number){this.rawPixels=newUint8ClampedArray(pixels);this.imageWidth=width;this.imageHeight=height;}setBrushSize(size:number){this.brushSize=size;}// 触摸开始startDraw(x:number,y:number){this.addRect(x,y);this.lastX=x;this.lastY=y;this.hasLast=true;}// 触摸移动(自动插值)updateDraw(x:number,y:number){if(!this.hasLast){this.startDraw(x,y);return;}constdx=x-this.lastX,dy=y-this.lastY;constdistance=Math.hypot(dx,dy);conststep=Math.max(1,this.brushSize/2);if(distance>step){conststeps=Math.ceil(distance/step);for(leti=1;i<=steps;i++){constt=i/steps;this.addRect(this.lastX+dx*t,this.lastY+dy*t);}}else{this.addRect(x,y);}this.lastX=x;this.lastY=y;}// 添加矩形并快速取色(取中心点,同步,极快)privateaddRect(centerX:number,centerY:number){consthalf=this.brushSize/2;constrect:DrawRect={x:centerX-half,y:centerY-half,width:this.brushSize,height:this.brushSize};// 去重:避免连续相同矩形constlast=this.currentRects[this.currentRects.length-1];if(last&&last.rect.x===rect.x&&last.rect.y===rect.y)return;constcolor=this.getColorAtCenter(rect);this.currentRects.push({rect,color});}// 取矩形中心点像素颜色(同步,极快,仅读取)privategetColorAtCenter(rect:DrawRect):string{if(!this.rawPixels)return'#888';letcx=Math.floor(rect.x+rect.width/2);letcy=Math.floor(rect.y+rect.height/2);cx=Math.min(this.imageWidth-1,Math.max(0,cx));cy=Math.min(this.imageHeight-1,Math.max(0,cy));constidx=(cy*this.imageWidth+cx)*4;constr=this.rawPixels[idx];constg=this.rawPixels[idx+1];constb=this.rawPixels[idx+2];return`rgb(${r},${g},${b})`;}// 实时绘制所有当前矩形drawCurrent(ctx:CanvasRenderingContext2D,imageRect:DrawRect){for(constitemofthis.currentRects){this.drawSingleRect(ctx,item.rect,item.color,imageRect);}}// 绘制单个矩形(图像坐标 → 屏幕坐标)drawSingleRect(ctx:CanvasRenderingContext2D,rect:DrawRect,color:string,imageRect:DrawRect){letleft=Math.max(0,Math.floor(rect.x));lettop=Math.max(0,Math.floor(rect.y));letright=Math.min(this.imageWidth,Math.ceil(rect.x+rect.width));letbottom=Math.min(this.imageHeight,Math.ceil(rect.y+rect.height));if(left>=right||top>=bottom)return;constscreenX=imageRect.x+(left/this.imageWidth)*imageRect.width;constscreenY=imageRect.y+(top/this.imageHeight)*imageRect.height;constscreenW=((right-left)/this.imageWidth)*imageRect.width;constscreenH=((bottom-top)/this.imageHeight)*imageRect.height;ctx.fillStyle=color;ctx.fillRect(screenX,screenY,screenW,screenH);}// 结束绘制,返回矩形列表(用于保存命令)endDraw():MosaicRect[]{constrects=[...this.currentRects];this.clear();returnrects;}clear(){this.currentRects=[];this.hasLast=false;}}3.5 在 CanvasManager 中集成
// 加载图片时,读取像素数据给马赛克管理器(仅用于取色)asyncsetOriginalImage(pixelMap:image.PixelMap){// ... 原有代码 ...constbuffer=newArrayBuffer(this.imageWidth*this.imageHeight*4);awaitpixelMap.readPixelsToBuffer(buffer);constpixels=newUint8ClampedArray(buffer);this.pixelMosaicManager.setRawPixels(pixels,this.imageWidth,this.imageHeight);// ...}// 触摸移动时实时绘制privatedrawCurrentPixelRects(){if(!this.canvasInfo)return;this.ctx.save();this.ctx.beginPath();this.ctx.rect(this.canvasInfo.imageRect.x,this.canvasInfo.imageRect.y,this.canvasInfo.imageRect.width,this.canvasInfo.imageRect.height);this.ctx.clip();this.pixelMosaicManager.drawCurrent(this.ctx,this.canvasInfo.imageRect);this.ctx.restore();}asynconTouchMove(canvasX:number,canvasY:number){// ...if(this.currentTool==='mosaic'&&this.currentMosaicMode===MosaicMode.PIXEL){this.pixelMosaicManager.updateDraw(imgX,imgY);this.drawCurrentPixelRects();// 实时看到马赛克}}// 触摸结束时保存命令asynconTouchEnd(){if(this.currentTool==='mosaic'&&this.currentMosaicMode===MosaicMode.PIXEL){constrects=this.pixelMosaicManager.endDraw();if(rects.length>0){constcommand={id:Date.now()+'',type:'pixelMosaic',rects,blockSize:this.currentSize};this.commands.push(command);awaitthis.redrawAll();// 重绘所有命令(确保最终效果)}}}3.6 历史命令重绘(CommandRenderer)
// 渲染所有命令时,对 pixelMosaic 类型直接使用保存的颜色绘制for(constcmdofcommands){if(cmd.type==='pixelMosaic'){for(constitemofcmd.rects){this.drawMosaicRect(item.rect,item.color);}}}四、性能对比与优化效果
| 测试场景 | 初版方案 | 最终方案 |
|---|---|---|
| 一次笔触(50个矩形) | 300ms 延迟 | <16ms 实时 |
| 连续涂抹10笔 | 卡顿明显,帧率 <20fps | 满帧 60fps |
| 撤销操作 | 重新处理所有矩形,延迟 >500ms | 直接重绘,<50ms |
| 内存占用 | 每次修改整图,不断 clone | 仅保存矩形坐标和颜色,极低 |
| 色调准确性 | 初版有未知色偏 | 完全忠于原图,无任何色偏 |
如何做到无色偏?
最终版从不修改原图,马赛克只是绘制在 Canvas 上层的彩色矩形,颜色直接从原图对应位置读取(只读),因此原图颜色被原样“挪”到马赛克块上,不可能出现色偏。
五、优化技巧与踩坑总结
5.1 坚决不修改原图
只要你的目标是“遮挡”而不是“变形”,就一定不要直接操作PixelMap。把它当作只读的调色板,所有的编辑效果都通过 Canvas 绘图指令叠加。
5.2 取色策略:中心点 vs 平均色
- 中心点颜色:计算量极小,适合实时预览,肉眼几乎看不出差异。
- 精确平均色:更准确但需要扫描矩形内所有像素,适合后台异步更新。
本文采用中心点颜色已经足够自然,因为矩形尺寸较小(通常 20~40px),中心点颜色能很好地代表整个区域。
5.3 插值步长的选择
步长设为brushSize / 2可以保证矩形之间有重叠,避免缝隙。步长太小会生成过多矩形,影响性能;步长太大则可能仍有缝隙。经过测试,brushSize / 2是最佳平衡点。
5.4 坐标转换的坑
Canvas 中图片是居中显示的,因此涂抹时获取的屏幕坐标需要转换为图像坐标系,绘制时再转换回屏幕坐标系。务必封装好这两个转换函数,避免到处重复计算。
privatecanvasToImageCoords(canvasX:number,canvasY:number):DrawPoint{...}privateimageToScreenCoords(imgX:number,imgY:number):DrawPoint{...}5.5 内存管理
原始像素缓存(rawPixels)会占用宽 × 高 × 4字节的内存。对于超大图片(例如 4000×3000),大约 48MB,可以接受。但若追求极致内存优化,可以改为按需读取,不过不推荐,因为会增加复杂度且影响性能。
六、结语
像素马赛克功能的核心难点在于性能和颜色准确性。初版因直接操作像素导致卡顿和色偏,最终通过“只读原图、实时绘制矩形”的方案,不仅让涂抹过程丝般顺滑,还彻底杜绝了色偏风险。希望本文的复盘能帮助你避开类似的坑,轻松实现高质量的图片编辑功能。完整代码下载工程查看。
如果你也有类似的问题或更好的实现思路,欢迎在评论区交流。