鸿蒙实战:图片编辑器——涂鸦、撤回与保存功能
摘要: 本文介绍了基于HarmonyOS 5.0+的图片编辑器实现方案,重点解决了涂鸦绘制、命令式撤回和图片保存三大功能。通过二次贝塞尔曲线实现线条平滑,采用命令模式精准控制多步撤销,利用Canvas裁剪技术确保绘制区域严格限制在图片范围内。核心实现包括绘制命令管理、涂鸦策略设计、撤回重绘机制以及图片区域精准保存,最终实现了高性能、无卡顿的图片编辑体验,并解决了快速滑动导致的边缘漏画问题。完整源码
完整源码:ImageEditor
基于 HarmonyOS 5.0+,实现图片编辑器的涂鸦画笔、命令模式撤回以及保存到相册功能。在上一节马赛克画笔的基础上,扩展涂鸦工具,并支持可靠的撤回和图片保存。本文详细记录实现过程、踩坑经验及最终方案。
一、效果演示

- 涂鸦绘制:(颜色选择、笔刷大小调节)
- 马赛克绘制:(纹理填充、笔刷大小调节)
- 撤回:可撤销多步绘制操作
- 保存到相册:仅保存图片区域,不含UI
- 绘制区域严格限制在图片内,快速滑动不穿帮
二、背景与目标
上一节我们实现了高性能纹理马赛克画笔,但功能还不完善。本节将重点完成:
- 涂鸦工具:支持颜色、笔刷调节,线条平滑
- 撤回机制:基于命令模式,精确撤销任意多步
- 保存图片:将编辑结果保存到系统相册
- 绘制区域限制:解决图片外绘制及快速滑动漏画问题
三、方案设计
3.1 撤回机制对比
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 截图快照 | 每步保存整张 PixelMap | 内存极大,有时机问题 |
| 命令模式 | 保存绘制命令(点、颜色、大小),撤回时重绘 | 内存小,精准可控 |
采用命令模式:每个绘制操作抽象为 DrawCommand 存入数组,撤回时弹出最后一条命令并重绘剩余命令。
3.2 涂鸦绘制设计
| 需求 | 技术方案 | 选型理由 |
|---|---|---|
| 线条平滑 | 二次贝塞尔曲线 | 拐角处平滑过渡 |
| 手感好 | 圆头笔刷 | lineCap = 'round' |
| 颜色选择 | 色盘 Circle 组件 | 预设颜色,可扩展 |
| 笔刷大小 | Slider 滑块 | 2-20px 可调 |
3.3 绘制区域限制演进
在开发过程中,Canvas是最大化的、图片居中绘制在Canvas上,但是涂鸦或马赛克在绘制时会画到图片区域外。如果解决这个问题我尝试过两种方案:
-
逐点判断:在
startDraw和updateDraw中检查点是否在图片矩形内。
问题:快速滑动时,两个触摸点之间距离较大,线段中间区域未被判断,导致图片边缘漏画(断线)。 -
Canvas 裁剪(最终方案):设置
ctx.clip()为图片矩形,所有绘制自动被裁剪。
优点:无漏画、性能好、实现简单。
四、核心实现
4.1 绘制命令定义
export interface DrawCommand {
id: string; // 唯一标识
type: DrawToolType; // draw 或 mosaic
points: Array<DrawPoint>; // 所有触摸点
color?: string; // 涂鸦颜色
size?: number; // 笔刷大小
}
4.2 涂鸦策略(贝塞尔曲线)
import { IDrawingStrategy } from './IDrawingStrategy';
import { DrawCommand } from '../model/DrawCommand';
import { DrawPoint } from '../model/DrawPoint';
export class DrawStrategy implements IDrawingStrategy {
private currentPath: Path2D = new Path2D(); // 当前绘制的路径
private points: Array<DrawPoint> = []; // 当前绘制的所有点(用于二次贝塞尔曲线)
private currentColor: string = '#ff0000'; // 当前笔刷颜色
private currentSize: number = 8; // 当前笔刷大小
// 开始绘制:初始化路径,记录起点
startDraw(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, x: number, y: number, color?: string, size?: number): void {
this.currentColor = color || '#ff0000';
this.currentSize = size || 8;
this.points = [{ x, y }];
this.currentPath = new Path2D();
this.currentPath.moveTo(x, y);
this.executeDraw(ctx);
}
// 连续绘制:使用二次贝塞尔曲线连接上一个点和当前点,使线条平滑
updateDraw(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, x: number, y: number): void {
if (this.points.length === 0) return;
const lastPoint = this.points[this.points.length - 1];
this.points.push({ x, y });
this.currentPath.quadraticCurveTo(lastPoint.x, lastPoint.y, x, y);
this.executeDraw(ctx);
}
// 结束绘制:无需额外处理
endDraw(): void {}
// 撤回时重绘:根据保存的命令重建路径并绘制
drawCommand(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, command: DrawCommand): void {
if (command.points.length < 2) return;
const path = new Path2D();
path.moveTo(command.points[0].x, command.points[0].y);
for (let i = 1; i < command.points.length; i++) {
const prev = command.points[i - 1];
const curr = command.points[i];
path.quadraticCurveTo(prev.x, prev.y, curr.x, curr.y);
}
ctx.save();
ctx.lineWidth = command.size || 8;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = command.color || '#ff0000';
ctx.stroke(path);
ctx.restore();
}
// 执行绘制:应用当前笔刷设置
private executeDraw(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D): void {
ctx.save();
ctx.lineWidth = this.currentSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = this.currentColor;
ctx.stroke(this.currentPath);
ctx.restore();
}
}
4.3 撤回与清空 CanvasManager.ets
private commands: DrawCommand[] = [];
// 撤回
async undo(): Promise<void> {
if (this.commands.length === 0) {
this.showToast('没有可撤回的操作');
return;
}
this.commands.pop();
await this.redrawAll();
this.showToast('已撤回');
}
// 清空
async clear(): Promise<void> {
this.commands = [];
// 如果之前有裁剪,恢复状态
if (this.isClipped) {
this.ctx.restore();
this.isClipped = false;
}
await this.renderOriginalImage();
this.showToast('已清空');
}
4.4 保存图片(仅保存图片区域)
saveImage(): image.PixelMap | null {
if (!this.originalImage || !this.canvasInfo) {
this.showToast('保存失败:未加载图片');
return null;
}
const rect = this.canvasInfo.imageRect;
try {
const pixelMap = this.ctx.getPixelMap(rect.x, rect.y, rect.width, rect.height);
if (!pixelMap) {
this.showToast('保存失败:获取图片数据为空');
}
return pixelMap;
} catch (err) {
console.error('保存图片失败:', err);
this.showToast('保存失败:' + err.message);
return null;
}
}
配合 ImageHelper.saveToAlbum 将 PixelMap 写入相册。最后我们引入EditorManager.ets 作为编辑管理,将CanvasManager与UI隔离开。
4.5 绘制区域限制(图片绘制与裁剪方案,解决漏画)
// 选择图片后不仅要绘制原图还要重新绘制命令,因为Canvas可能尺寸会变化,变化会导致重新绘制。
async setOriginalImage(pixelMap: image.PixelMap): Promise<void> {
this.originalImage = pixelMap;
await this.renderOriginalImage();
await this.redrawAll()
}
// 根据手势开始绘制 并存储绘制命令。
startDraw(x: number, y: number): void {
if (!this.canvasInfo?.imageRect) return;
const rect = this.canvasInfo.imageRect;
// 起点必须在图片区域内,否则不开始绘制(不能画)
if (x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height) return;
// 如果还没有设置裁剪,则保存状态并设置裁剪区域(仅一次)
if (!this.isClipped) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(rect.x, rect.y, rect.width, rect.height);
this.ctx.clip();
this.isClipped = true;
}
const commandId = Date.now().toString() + Math.random();
this.currentCommand = {
id: commandId,
type: this.currentTool,
points: [{ x, y }],
color: this.currentColor,
size: this.currentSize
};
if (this.currentTool === 'draw') {
this.drawStrategy.startDraw(this.ctx, x, y, this.currentColor, this.currentSize);
} else if (this.currentTool === 'mosaic') {
this.mosaicStrategy.startDraw(this.ctx, x, y, undefined, this.currentSize);
}
}
// 重绘所有命令时,先恢复裁剪,重绘原图,再重新裁剪
async redrawAll(): Promise<void> {
// 1. 恢复裁剪(如果有)
if (this.isClipped) {
this.ctx.restore();
this.isClipped = false;
}
// 2. 重绘原图
await this.renderOriginalImage();
// 如果没有命令,直接返回
if (this.commands.length === 0) return;
// 3. 重新设置裁剪
const rect = this.canvasInfo!.imageRect;
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(rect.x, rect.y, rect.width, rect.height);
this.ctx.clip();
this.isClipped = true;
// 4. 重绘所有命令
for (const cmd of this.commands) {
if (cmd.type === 'draw') {
this.drawStrategy.drawCommand(this.ctx, cmd);
} else {
this.mosaicStrategy.drawCommand(this.ctx, cmd);
}
}
}
4.6 Index主界面布局
build() {
if (!this.currentImage) {
/* 引导页 选择照片 */
}
else {
Column() {
TopToolBar(...) // 顶部工具栏
Canvas(this.ctx)
.layoutWeight(1) // 自动填满剩余高度
.width('100%')
.gesture(PanGesture(...))
Column() { // 底部固定区域
if (this.currentTool === 'draw') {
// 涂鸦配置面板(颜色滚动 + 笔刷滑块)
}
if (this.currentTool === 'mosaic') {
// 马赛克配置面板
}
DrawToolBar(...)
}
.width('100%')
}
.width('100%')
.height('100%')
}
}
避免采用底层与浮动会导致工具栏遮挡绘制区域。图片编辑有很多功能代码有点多、文章中对于核心内容详细讲解、完整代码可以查看工程代码。关于马赛克也做了简单的调整,不再依据纹理图片而是自己实现纹理转换成图片。
五、关键技术点
| 功能 | 实现方案 | 关键代码 |
|---|---|---|
| 涂鸦平滑 | 二次贝塞尔曲线 | quadraticCurveTo |
| 撤回 | 命令模式 + 重绘 | commands.pop(); redrawAll() |
| 保存图片 | 截取图片区域 PixelMap | ctx.getPixelMap(imageRect) |
| 区域限制 | Canvas 裁剪 | ctx.clip() |
| 手势响应 | PanGesture | onActionStart/Update/End |
六、踩坑经验总结
- 漏画问题:不要通过逐点判断来限制绘制区域,快速滑动必然漏画。使用
clip()是最简且可靠的方案。 - 保存区域:必须用
imageRect截取,才能只保存图片内容,不含周围黑色背景和 UI比截图方式效率高。 - 布局:用
layoutWeight让 Canvas 自然填充,避免绝对定位遮挡。 - 手势穿透:
Stack中的绝对定位组件会拦截手势,需要设置hitTestBehavior(Transparent),但最好直接用Column布局。
七、与上一节马赛克的关系
本节在上一节的基础上:
- 复用策略接口
IDrawingStrategy,涂鸦和马赛克统一调度。 - 共享命令栈,两种工具都支持撤回。
- 扩展配置面板,涂鸦模式显示颜色 + 笔刷,马赛克模式只显示笔刷。
八、总结
本文完整实现了图片编辑器的三大核心功能:涂鸦、撤回、保存,而且完善解决了涂鸦与马赛克绘制超出图片区域问题。后续可扩展:裁剪、旋转、贴纸、文字等。如果觉得本文对你有帮助,请点赞、收藏、转发支持!
更多推荐




所有评论(0)