完整源码ImageEditor

基于 HarmonyOS 5.0+,实现图片编辑器的涂鸦画笔、命令模式撤回以及保存到相册功能。在上一节马赛克画笔的基础上,扩展涂鸦工具,并支持可靠的撤回和图片保存。本文详细记录实现过程、踩坑经验及最终方案。

一、效果演示

涂鸦撤回保存.gif

  • 涂鸦绘制:(颜色选择、笔刷大小调节)
  • 马赛克绘制:(纹理填充、笔刷大小调节)
  • 撤回:可撤销多步绘制操作
  • 保存到相册:仅保存图片区域,不含UI
  • 绘制区域严格限制在图片内,快速滑动不穿帮

二、背景与目标

上一节我们实现了高性能纹理马赛克画笔,但功能还不完善。本节将重点完成:

  • 涂鸦工具:支持颜色、笔刷调节,线条平滑
  • 撤回机制:基于命令模式,精确撤销任意多步
  • 保存图片:将编辑结果保存到系统相册
  • 绘制区域限制:解决图片外绘制及快速滑动漏画问题

三、方案设计

3.1 撤回机制对比

方案 原理 优缺点
截图快照 每步保存整张 PixelMap 内存极大,有时机问题
命令模式 保存绘制命令(点、颜色、大小),撤回时重绘 内存小,精准可控

采用命令模式:每个绘制操作抽象为 DrawCommand 存入数组,撤回时弹出最后一条命令并重绘剩余命令。

3.2 涂鸦绘制设计

需求 技术方案 选型理由
线条平滑 二次贝塞尔曲线 拐角处平滑过渡
手感好 圆头笔刷 lineCap = 'round'
颜色选择 色盘 Circle 组件 预设颜色,可扩展
笔刷大小 Slider 滑块 2-20px 可调

3.3 绘制区域限制演进

在开发过程中,Canvas是最大化的、图片居中绘制在Canvas上,但是涂鸦或马赛克在绘制时会画到图片区域外。如果解决这个问题我尝试过两种方案:

  1. 逐点判断:在 startDrawupdateDraw 中检查点是否在图片矩形内。
    问题:快速滑动时,两个触摸点之间距离较大,线段中间区域未被判断,导致图片边缘漏画(断线)。

  2. 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

六、踩坑经验总结

  1. 漏画问题:不要通过逐点判断来限制绘制区域,快速滑动必然漏画。使用 clip() 是最简且可靠的方案。
  2. 保存区域:必须用 imageRect 截取,才能只保存图片内容,不含周围黑色背景和 UI比截图方式效率高。
  3. 布局:用 layoutWeight 让 Canvas 自然填充,避免绝对定位遮挡。
  4. 手势穿透Stack 中的绝对定位组件会拦截手势,需要设置 hitTestBehavior(Transparent),但最好直接用 Column 布局。

七、与上一节马赛克的关系

本节在上一节的基础上:

  • 复用策略接口 IDrawingStrategy,涂鸦和马赛克统一调度。
  • 共享命令栈,两种工具都支持撤回。
  • 扩展配置面板,涂鸦模式显示颜色 + 笔刷,马赛克模式只显示笔刷。

八、总结

本文完整实现了图片编辑器的三大核心功能:涂鸦、撤回、保存,而且完善解决了涂鸦与马赛克绘制超出图片区域问题。后续可扩展:裁剪、旋转、贴纸、文字等。如果觉得本文对你有帮助,请点赞、收藏、转发支持!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐