鸿蒙实战:图片编辑器——像素马赛克从卡顿到丝滑的终极优化
我在开发鸿蒙图片编辑器时,实现像素马赛克功能遇到了两大噩梦:绘制卡顿和图片色调异常。经过多次重构,最终找到了一套既简单又高效的方案。本文将完整复盘踩坑过程与解决方案,希望能帮你少走弯路。
完整源码:ImageEditor
一、需求与初步尝试
图片编辑器中,马赛克画笔常用于遮盖人脸、车牌、文字等敏感信息。常见的实现方式有两种:
- 纹理马赛克:用预设的纹理图片(如小方格图片)重复填充涂抹区域。
- 像素马赛克:将涂抹区域划分为一个个方块,每个方块的颜色取原图该方块内所有像素的平均色。
纹理马赛克性能极佳,但效果像贴了一层“瓷砖”,不够自然。因此我选择了更真实的像素马赛克。
1.1 初版思路(错误示范)
大致流程:
- 用户在 Canvas 上涂抹,记录所有矩形的坐标。
- 每次涂抹结束,将所有矩形和原图传入
TaskPool,在后台线程中逐矩形计算平均色,修改整张图片的像素。 - 将修改后的图片重新绘制到 Canvas 上;撤销时重新执行所有历史矩形。
这种思路在逻辑上说得通,但实际运行时卡成 ppt,而且因为直接操作像素缓冲区,出现了未知的色调偏移(红变蓝、整体偏色)。
二、问题根源分析
2.1 卡顿:每次修改整张图片,而非增量绘制
初版在每笔涂抹结束后,都对整张图片的所有矩形重新做一遍像素处理。随着矩形数量增加(一次快速涂抹可能产生上百个矩形),处理时间线性增长。用户手指一抬,要等几百毫秒才能看到马赛克,体验极差。
2.2 色调异常:直接修改像素极易出错
从 readPixelsToBuffer 获取的原始像素数据,其内存布局、颜色空间、Alpha 通道的处理等细节与预期可能存在差异。多次调整通道顺序仍无法完全消除色偏。这促使我寻找完全不修改原图的替代方案——既然马赛克只是“遮挡”,何必改变底图呢?
2.3 其他问题
- 没有插值:手指滑动过快时,触摸事件采样点间隔较大,导致马赛克块之间出现缝隙。
- 重复计算:历史重绘时又重新扫描像素,浪费 CPU。
三、解决方案:放弃修改原图,采用实时绘制 + 颜色缓存
核心思想:不再修改原图,而是将马赛克视为独立的绘图指令,直接在 Canvas 上层绘制矩形。每个矩形在生成时就计算好颜色并缓存,重绘时直接使用缓存的颜色,无需再触碰像素数据。
这样做的好处:
- 原图永远不变,彻底避免像素操作带来的色偏风险。
- 绘制速度只与矩形数量有关,与图片尺寸无关。
- 撤销/重绘仅需重新执行绘图指令,极快。
3.1 运行效果

3.2 整体架构
用户涂抹 → 生成矩形 → 立即取中心点颜色(同步)→ 绘制到 Canvas(实时预览)
↓
涂抹结束 → 保存矩形+颜色 → 入命令栈
↓
撤销/重绘 → 直接使用保存的颜色重新绘制
3.3 数据结构
// 带颜色的马赛克矩形
export interface MosaicRect {
rect: DrawRect; // {x, y, width, height} 图像坐标系
color: string; // "rgb(r,g,b)"
}
// 历史命令
export interface PixelMosaicCommand {
id: string;
type: 'pixelMosaic';
rects: MosaicRect[];
blockSize: number;
}
3.4 像素马赛克管理器(核心代码)
// PixelMosaicManager.ets
import { DrawRect } from '../model/CanvasInfo';
export class PixelMosaicManager {
private currentRects: MosaicRect[] = [];
private brushSize = 20;
private rawPixels: Uint8ClampedArray | null = null;
private imageWidth = 0, imageHeight = 0;
private lastX = 0, lastY = 0, hasLast = false;
// 设置原始像素数据(仅用于取色,不修改)
setRawPixels(pixels: Uint8ClampedArray, width: number, height: number) {
this.rawPixels = new Uint8ClampedArray(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; }
const dx = x - this.lastX, dy = y - this.lastY;
const distance = Math.hypot(dx, dy);
const step = Math.max(1, this.brushSize / 2);
if (distance > step) {
const steps = Math.ceil(distance / step);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
this.addRect(this.lastX + dx * t, this.lastY + dy * t);
}
} else {
this.addRect(x, y);
}
this.lastX = x; this.lastY = y;
}
// 添加矩形并快速取色(取中心点,同步,极快)
private addRect(centerX: number, centerY: number) {
const half = this.brushSize / 2;
const rect: DrawRect = {
x: centerX - half, y: centerY - half,
width: this.brushSize, height: this.brushSize
};
// 去重:避免连续相同矩形
const last = this.currentRects[this.currentRects.length - 1];
if (last && last.rect.x === rect.x && last.rect.y === rect.y) return;
const color = this.getColorAtCenter(rect);
this.currentRects.push({ rect, color });
}
// 取矩形中心点像素颜色(同步,极快,仅读取)
private getColorAtCenter(rect: DrawRect): string {
if (!this.rawPixels) return '#888';
let cx = Math.floor(rect.x + rect.width / 2);
let cy = 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));
const idx = (cy * this.imageWidth + cx) * 4;
const r = this.rawPixels[idx];
const g = this.rawPixels[idx + 1];
const b = this.rawPixels[idx + 2];
return `rgb(${r}, ${g}, ${b})`;
}
// 实时绘制所有当前矩形
drawCurrent(ctx: CanvasRenderingContext2D, imageRect: DrawRect) {
for (const item of this.currentRects) {
this.drawSingleRect(ctx, item.rect, item.color, imageRect);
}
}
// 绘制单个矩形(图像坐标 → 屏幕坐标)
drawSingleRect(ctx: CanvasRenderingContext2D, rect: DrawRect, color: string, imageRect: DrawRect) {
let left = Math.max(0, Math.floor(rect.x));
let top = Math.max(0, Math.floor(rect.y));
let right = Math.min(this.imageWidth, Math.ceil(rect.x + rect.width));
let bottom = Math.min(this.imageHeight, Math.ceil(rect.y + rect.height));
if (left >= right || top >= bottom) return;
const screenX = imageRect.x + (left / this.imageWidth) * imageRect.width;
const screenY = imageRect.y + (top / this.imageHeight) * imageRect.height;
const screenW = ((right - left) / this.imageWidth) * imageRect.width;
const screenH = ((bottom - top) / this.imageHeight) * imageRect.height;
ctx.fillStyle = color;
ctx.fillRect(screenX, screenY, screenW, screenH);
}
// 结束绘制,返回矩形列表(用于保存命令)
endDraw(): MosaicRect[] {
const rects = [...this.currentRects];
this.clear();
return rects;
}
clear() {
this.currentRects = [];
this.hasLast = false;
}
}
3.5 在 CanvasManager 中集成
// 加载图片时,读取像素数据给马赛克管理器(仅用于取色)
async setOriginalImage(pixelMap: image.PixelMap) {
// ... 原有代码 ...
const buffer = new ArrayBuffer(this.imageWidth * this.imageHeight * 4);
await pixelMap.readPixelsToBuffer(buffer);
const pixels = new Uint8ClampedArray(buffer);
this.pixelMosaicManager.setRawPixels(pixels, this.imageWidth, this.imageHeight);
// ...
}
// 触摸移动时实时绘制
private drawCurrentPixelRects() {
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();
}
async onTouchMove(canvasX: number, canvasY: number) {
// ...
if (this.currentTool === 'mosaic' && this.currentMosaicMode === MosaicMode.PIXEL) {
this.pixelMosaicManager.updateDraw(imgX, imgY);
this.drawCurrentPixelRects(); // 实时看到马赛克
}
}
// 触摸结束时保存命令
async onTouchEnd() {
if (this.currentTool === 'mosaic' && this.currentMosaicMode === MosaicMode.PIXEL) {
const rects = this.pixelMosaicManager.endDraw();
if (rects.length > 0) {
const command = { id: Date.now() + '', type: 'pixelMosaic', rects, blockSize: this.currentSize };
this.commands.push(command);
await this.redrawAll(); // 重绘所有命令(确保最终效果)
}
}
}
3.6 历史命令重绘(CommandRenderer)
// 渲染所有命令时,对 pixelMosaic 类型直接使用保存的颜色绘制
for (const cmd of commands) {
if (cmd.type === 'pixelMosaic') {
for (const item of cmd.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 中图片是居中显示的,因此涂抹时获取的屏幕坐标需要转换为图像坐标系,绘制时再转换回屏幕坐标系。务必封装好这两个转换函数,避免到处重复计算。
private canvasToImageCoords(canvasX: number, canvasY: number): DrawPoint { ... }
private imageToScreenCoords(imgX: number, imgY: number): DrawPoint { ... }
5.5 内存管理
原始像素缓存(rawPixels)会占用 宽 × 高 × 4 字节的内存。对于超大图片(例如 4000×3000),大约 48MB,可以接受。但若追求极致内存优化,可以改为按需读取,不过不推荐,因为会增加复杂度且影响性能。
六、结语
像素马赛克功能的核心难点在于性能和颜色准确性。初版因直接操作像素导致卡顿和色偏,最终通过“只读原图、实时绘制矩形”的方案,不仅让涂抹过程丝般顺滑,还彻底杜绝了色偏风险。希望本文的复盘能帮助你避开类似的坑,轻松实现高质量的图片编辑功能。完整代码下载工程查看。
如果你也有类似的问题或更好的实现思路,欢迎在评论区交流。
更多推荐

所有评论(0)