HarmonyOS APP开发:SVG编辑器与矢量绘图工具

举报
Jack20 发表于 2026/06/22 22:25:26 2026/06/22
【摘要】 HarmonyOS APP开发:SVG编辑器与矢量绘图工具📌 核心要点:从架构设计到功能实现,构建完整的SVG编辑器,涵盖路径编辑、变换操作、图层管理与导出序列化,打造专业级矢量绘图工具。 一、背景与动机你有没有用过Figma、Sketch或者Inkscape?这些矢量编辑器的核心能力是什么?不是画个圆画个方那么简单——而是能编辑:选中一个图形,拖动控制点改变形状,旋转缩放调整位置,调整...

HarmonyOS APP开发:SVG编辑器与矢量绘图工具

📌 核心要点:从架构设计到功能实现,构建完整的SVG编辑器,涵盖路径编辑、变换操作、图层管理与导出序列化,打造专业级矢量绘图工具。


一、背景与动机

你有没有用过Figma、Sketch或者Inkscape?这些矢量编辑器的核心能力是什么?不是画个圆画个方那么简单——而是能编辑:选中一个图形,拖动控制点改变形状,旋转缩放调整位置,调整图层顺序,最后导出为标准SVG文件。

在HarmonyOS上做一个SVG编辑器,听起来像是个"大工程",但拆解开来,核心功能模块其实很清晰:路径编辑、变换操作、图层管理、导出序列化。每个模块都有成熟的设计模式可以参考,关键是把它们有机地组合在一起。

为什么要在HarmonyOS上做SVG编辑器?因为移动端的矢量绘图工具太少了。设计师在电脑上画好图标,想在手机上微调一下?抱歉,没有趁手的工具。开发者想快速画个SVG占位图?只能打开网页工具。如果我们能在HarmonyOS上提供一个轻量但功能完整的SVG编辑器,这些痛点就能解决。

当然,做一个"能用"的编辑器不难,做一个"好用"的编辑器却不容易。路径编辑需要精确的贝塞尔曲线控制,变换操作需要流畅的手势交互,图层管理需要直观的拖拽排序,导出序列化需要严格遵循SVG规范……每一个环节都需要精心设计。

今天这篇文章,我们就从架构到实现,一步步构建一个完整的SVG编辑器。准备好了吗?让我们开始吧。


二、核心原理

2.1 SVG编辑器架构设计

SVG编辑器的核心架构采用MVC模式,分为三层:数据模型层(Model)、视图渲染层(View)、交互控制层(Controller)。

graph TD
    A[交互控制层 Controller]:::primary --> B[数据模型层 Model]:::info
    B --> C[视图渲染层 View]:::warning
    C -->|用户操作| A
    
    B --> D[SVG文档模型]:::info
    B --> E[变换矩阵]:::info
    B --> F[图层树]:::info
    
    A --> G[选择工具]:::primary
    A --> H[路径工具]:::primary
    A --> I[变换工具]:::primary
    
    D --> J[序列化/导出]:::error
    
    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff

2.2 SVG文档对象模型

SVG编辑器的数据模型本质上是一棵元素树:

SvgDocument
├── Defs (滤镜、渐变等定义)
├── Layer (图层1)
│   ├── Path (路径元素)
│   ├── Rect (矩形元素)
│   └── Circle (圆形元素)
├── Layer (图层2)
│   ├── Text (文字元素)
│   └── Group (分组元素)
└── Layer (图层3)

每个元素都有以下核心属性:

  • 变换矩阵:记录平移、旋转、缩放
  • 样式属性:fill、stroke、opacity等
  • 几何数据:路径d、矩形宽高、圆心半径等
  • 图层信息:所属图层、z-index顺序

2.3 变换矩阵原理

SVG的变换操作(平移、旋转、缩放)都通过3×3仿射变换矩阵实现:

| a  c  tx |   a, d = 缩放因子
| b  d  ty |   b, c = 倾斜因子
| 0  0  1  |   tx, ty = 平移偏移

矩阵乘法满足结合律但不满足交换律,因此变换顺序很重要:先旋转再平移 ≠ 先平移再旋转。

2.4 贝塞尔曲线编辑原理

SVG路径中的贝塞尔曲线通过控制点定义形状:

  • 二次贝塞尔曲线(Q命令):1个控制点 + 1个终点
  • 三次贝塞尔曲线(C命令):2个控制点 + 1个终点

编辑贝塞尔曲线时,拖动控制点会改变曲线的弯曲方向和程度,拖动端点会改变曲线的起止位置。


三、代码实战

3.1 基础用法:SVG文档模型与变换操作

先定义SVG编辑器的核心数据模型和变换操作:

// SVG元素基类
class SvgElement {
  id: string;                    // 元素唯一标识
  type: SvgElementType;          // 元素类型
  transform: Matrix2D;           // 变换矩阵
  fill: string;                  // 填充颜色
  stroke: string;                // 描边颜色
  strokeWidth: number;           // 描边宽度
  opacity: number;               // 透明度
  visible: boolean;              // 是否可见
  locked: boolean;               // 是否锁定
  layerIndex: number;            // 所属图层索引
  zIndex: number;                // 层叠顺序

  constructor(type: SvgElementType, id?: string) {
    this.id = id || `elem_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
    this.type = type;
    this.transform = Matrix2D.identity();
    this.fill = '#333333';
    this.stroke = 'none';
    this.strokeWidth = 1;
    this.opacity = 1.0;
    this.visible = true;
    this.locked = false;
    this.layerIndex = 0;
    this.zIndex = 0;
  }

  // 获取包围盒
  getBounds(): Bounds {
    // 子类重写
    return { x: 0, y: 0, width: 0, height: 0 };
  }

  // 判断点是否在元素内
  containsPoint(px: number, py: number): boolean {
    const bounds = this.getBounds();
    return px >= bounds.x && px <= bounds.x + bounds.width &&
           py >= bounds.y && py <= bounds.y + bounds.height;
  }

  // 序列化为SVG属性字符串
  toSvgAttributes(): string {
    const attrs: string[] = [];
    if (this.fill !== 'none') attrs.push(`fill="${this.fill}"`);
    if (this.stroke !== 'none') attrs.push(`stroke="${this.stroke}"`);
    if (this.strokeWidth !== 1) attrs.push(`stroke-width="${this.strokeWidth}"`);
    if (this.opacity !== 1.0) attrs.push(`opacity="${this.opacity}"`);
    if (!this.transform.isIdentity()) {
      attrs.push(`transform="${this.transform.toSvgString()}"`);
    }
    return attrs.join(' ');
  }
}

// 2D变换矩阵
class Matrix2D {
  a: number;   // 水平缩放
  b: number;   // 垂直倾斜
  c: number;   // 水平倾斜
  d: number;   // 垂直缩放
  tx: number;  // 水平平移
  ty: number;  // 垂直平移

  constructor(a: number = 1, b: number = 0, c: number = 0, d: number = 1,
              tx: number = 0, ty: number = 0) {
    this.a = a; this.b = b; this.c = c; this.d = d;
    this.tx = tx; this.ty = ty;
  }

  // 单位矩阵
  static identity(): Matrix2D {
    return new Matrix2D(1, 0, 0, 1, 0, 0);
  }

  // 平移
  static translate(tx: number, ty: number): Matrix2D {
    return new Matrix2D(1, 0, 0, 1, tx, ty);
  }

  // 缩放
  static scale(sx: number, sy: number): Matrix2D {
    return new Matrix2D(sx, 0, 0, sy, 0, 0);
  }

  // 旋转(弧度制)
  static rotate(angle: number): Matrix2D {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return new Matrix2D(cos, sin, -sin, cos, 0, 0);
  }

  // 矩阵乘法
  multiply(other: Matrix2D): Matrix2D {
    return new Matrix2D(
      this.a * other.a + this.c * other.b,
      this.b * other.a + this.d * other.b,
      this.a * other.c + this.c * other.d,
      this.b * other.c + this.d * other.d,
      this.a * other.tx + this.c * other.ty + this.tx,
      this.b * other.tx + this.d * other.ty + this.ty
    );
  }

  // 变换点坐标
  transformPoint(x: number, y: number): Point {
    return {
      x: this.a * x + this.c * y + this.tx,
      y: this.b * x + this.d * y + this.ty
    };
  }

  // 是否为单位矩阵
  isIdentity(): boolean {
    return this.a === 1 && this.b === 0 && this.c === 0 &&
           this.d === 1 && this.tx === 0 && this.ty === 0;
  }

  // 转为SVG transform字符串
  toSvgString(): string {
    const parts: string[] = [];
    if (this.tx !== 0 || this.ty !== 0) {
      parts.push(`translate(${this.tx.toFixed(2)}, ${this.ty.toFixed(2)})`);
    }
    if (this.a !== 1 || this.d !== 1 || this.b !== 0 || this.c !== 0) {
      parts.push(`matrix(${this.a.toFixed(4)}, ${this.b.toFixed(4)}, ${this.c.toFixed(4)}, ${this.d.toFixed(4)}, 0, 0)`);
    }
    return parts.join(' ');
  }
}

// 矩形元素
class SvgRect extends SvgElement {
  x: number;
  y: number;
  width: number;
  height: number;
  rx: number;  // 圆角
  ry: number;

  constructor(x: number, y: number, w: number, h: number) {
    super(SvgElementType.RECT);
    this.x = x; this.y = y;
    this.width = w; this.height = h;
    this.rx = 0; this.ry = 0;
  }

  getBounds(): Bounds {
    return { x: this.x, y: this.y, width: this.width, height: this.height };
  }

  // 序列化为SVG标签
  toSvgString(): string {
    return `<rect x="${this.x}" y="${this.y}" width="${this.width}" height="${this.height}" ` +
           `rx="${this.rx}" ry="${this.ry}" ${this.toSvgAttributes()} />`;
  }
}

// 圆形元素
class SvgCircle extends SvgElement {
  cx: number;
  cy: number;
  r: number;

  constructor(cx: number, cy: number, r: number) {
    super(SvgElementType.CIRCLE);
    this.cx = cx; this.cy = cy; this.r = r;
  }

  getBounds(): Bounds {
    return {
      x: this.cx - this.r,
      y: this.cy - this.r,
      width: this.r * 2,
      height: this.r * 2
    };
  }

  toSvgString(): string {
    return `<circle cx="${this.cx}" cy="${this.cy}" r="${this.r}" ${this.toSvgAttributes()} />`;
  }
}

// 路径元素
class SvgPath extends SvgElement {
  d: string;  // 路径数据
  commands: PathCommand[];  // 解析后的命令列表

  constructor(d: string) {
    super(SvgElementType.PATH);
    this.d = d;
    this.commands = PathParser.parse(d);
  }

  getBounds(): Bounds {
    // 从路径命令中计算包围盒
    let minX = Infinity, minY = Infinity;
    let maxX = -Infinity, maxY = -Infinity;
    let curX = 0, curY = 0;

    for (const cmd of this.commands) {
      if (cmd.type === 'M' || cmd.type === 'L') {
        curX = cmd.params[0];
        curY = cmd.params[1];
      } else if (cmd.type === 'C') {
        // 三次贝塞尔曲线,取所有控制点和端点
        for (let i = 0; i < cmd.params.length; i += 2) {
          curX = cmd.params[i];
          curY = cmd.params[i + 1];
        }
      } else if (cmd.type === 'Z') {
        curX = 0; curY = 0;
      }
      minX = Math.min(minX, curX);
      minY = Math.min(minY, curY);
      maxX = Math.max(maxX, curX);
      maxY = Math.max(maxY, curY);
    }

    return {
      x: minX,
      y: minY,
      width: maxX - minX,
      height: maxY - minY
    };
  }

  toSvgString(): string {
    return `<path d="${this.d}" ${this.toSvgAttributes()} />`;
  }
}

// 枚举与接口定义
enum SvgElementType {
  RECT = 'rect',
  CIRCLE = 'circle',
  ELLIPSE = 'ellipse',
  PATH = 'path',
  LINE = 'line',
  TEXT = 'text',
  GROUP = 'group'
}

interface Bounds {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Point {
  x: number;
  y: number;
}

interface PathCommand {
  type: string;
  params: number[];
}

3.2 进阶用法:图层管理与SVG序列化

图层管理和导出序列化是编辑器的核心功能:

// 图层管理器
class LayerManager {
  private layers: SvgLayer[] = [];
  private activeLayerIndex: number = 0;

  // 创建新图层
  createLayer(name: string): SvgLayer {
    const layer: SvgLayer = {
      id: `layer_${Date.now()}`,
      name: name,
      visible: true,
      locked: false,
      opacity: 1.0,
      elements: [],
      order: this.layers.length
    };
    this.layers.push(layer);
    this.activeLayerIndex = this.layers.length - 1;
    return layer;
  }

  // 删除图层
  deleteLayer(index: number): void {
    if (this.layers.length <= 1) return;  // 至少保留一个图层
    this.layers.splice(index, 1);
    if (this.activeLayerIndex >= this.layers.length) {
      this.activeLayerIndex = this.layers.length - 1;
    }
  }

  // 移动图层顺序
  moveLayer(fromIndex: number, toIndex: number): void {
    if (fromIndex === toIndex) return;
    const layer = this.layers.splice(fromIndex, 1)[0];
    this.layers.splice(toIndex, 0, layer);
    // 更新order
    this.layers.forEach((l, i) => l.order = i);
  }

  // 获取当前活动图层
  getActiveLayer(): SvgLayer {
    return this.layers[this.activeLayerIndex];
  }

  // 获取所有图层
  getLayers(): SvgLayer[] {
    return this.layers;
  }

  // 向当前图层添加元素
  addElement(element: SvgElement): void {
    element.layerIndex = this.activeLayerIndex;
    element.zIndex = this.getActiveLayer().elements.length;
    this.getActiveLayer().elements.push(element);
  }

  // 从图层中移除元素
  removeElement(elementId: string): SvgElement | null {
    for (const layer of this.layers) {
      const idx = layer.elements.findIndex(e => e.id === elementId);
      if (idx >= 0) {
        return layer.elements.splice(idx, 1)[0];
      }
    }
    return null;
  }

  // 获取所有可见元素(按图层顺序和z-index排序)
  getAllVisibleElements(): SvgElement[] {
    const result: SvgElement[] = [];
    for (const layer of this.layers) {
      if (!layer.visible) continue;
      const visible = layer.elements
        .filter(e => e.visible)
        .sort((a, b) => a.zIndex - b.zIndex);
      result.push(...visible);
    }
    return result;
  }

  // 根据ID查找元素
  findElement(elementId: string): SvgElement | null {
    for (const layer of this.layers) {
      const found = layer.elements.find(e => e.id === elementId);
      if (found) return found;
    }
    return null;
  }
}

interface SvgLayer {
  id: string;
  name: string;
  visible: boolean;
  locked: boolean;
  opacity: number;
  elements: SvgElement[];
  order: number;
}

// SVG序列化器
class SvgSerializer {
  private width: number;
  private height: number;
  private viewBox: string;

  constructor(width: number = 800, height: number = 600) {
    this.width = width;
    this.height = height;
    this.viewBox = `0 0 ${width} ${height}`;
  }

  // 将图层管理器中的数据序列化为SVG字符串
  serialize(layerManager: LayerManager): string {
    const lines: string[] = [];
    // SVG头部
    lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
    lines.push(`<svg xmlns="http://www.w3.org/2000/svg" ` +
               `width="${this.width}" height="${this.height}" ` +
               `viewBox="${this.viewBox}">`);

    // 遍历图层
    const layers = layerManager.getLayers();
    for (const layer of layers) {
      if (!layer.visible) continue;

      lines.push(`  <g id="${layer.id}" opacity="${layer.opacity}">`);

      // 遍历图层中的元素
      const sortedElements = [...layer.elements].sort((a, b) => a.zIndex - b.zIndex);
      for (const element of sortedElements) {
        if (!element.visible) continue;
        lines.push(`    ${element.toSvgString()}`);
      }

      lines.push(`  </g>`);
    }

    lines.push(`</svg>`);
    return lines.join('\n');
  }

  // 反序列化:从SVG字符串解析为元素列表
  deserialize(svgString: string): SvgElement[] {
    const elements: SvgElement[] = [];
    // 简化版解析:正则匹配常见元素
    // 实际项目中应使用XML解析器

    // 匹配rect元素
    const rectRegex = /<rect\s+([^>]+)\/>/g;
    let match;
    while ((match = rectRegex.exec(svgString)) !== null) {
      const attrs = this.parseAttributes(match[1]);
      const rect = new SvgRect(
        parseFloat(attrs.x || '0'),
        parseFloat(attrs.y || '0'),
        parseFloat(attrs.width || '0'),
        parseFloat(attrs.height || '0')
      );
      if (attrs.fill) rect.fill = attrs.fill;
      if (attrs.stroke) rect.stroke = attrs.stroke;
      elements.push(rect);
    }

    // 匹配circle元素
    const circleRegex = /<circle\s+([^>]+)\/>/g;
    while ((match = circleRegex.exec(svgString)) !== null) {
      const attrs = this.parseAttributes(match[1]);
      const circle = new SvgCircle(
        parseFloat(attrs.cx || '0'),
        parseFloat(attrs.cy || '0'),
        parseFloat(attrs.r || '0')
      );
      if (attrs.fill) circle.fill = attrs.fill;
      elements.push(circle);
    }

    // 匹配path元素
    const pathRegex = /<path\s+([^>]+)\/>/g;
    while ((match = pathRegex.exec(svgString)) !== null) {
      const attrs = this.parseAttributes(match[1]);
      if (attrs.d) {
        const path = new SvgPath(attrs.d);
        if (attrs.fill) path.fill = attrs.fill;
        elements.push(path);
      }
    }

    return elements;
  }

  // 解析属性字符串
  private parseAttributes(attrString: string): Record<string, string> {
    const result: Record<string, string> = {};
    const attrRegex = /(\w[\w-]*)\s*=\s*"([^"]*)"/g;
    let attrMatch;
    while ((attrMatch = attrRegex.exec(attrString)) !== null) {
      result[attrMatch[1]] = attrMatch[2];
    }
    return result;
  }
}

// 路径解析器(简化版)
class PathParser {
  static parse(d: string): PathCommand[] {
    const commands: PathCommand[] = [];
    const regex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
    let match;
    while ((match = regex.exec(d)) !== null) {
      const type = match[1];
      const paramsStr = match[2].trim();
      const params = paramsStr ? paramsStr.split(/[\s,]+/).map(Number) : [];
      commands.push({ type, params });
    }
    return commands;
  }
}

3.3 完整示例:SVG编辑器实战

下面是一个功能完整的SVG编辑器,集成了绘图工具、选择工具、变换操作、图层管理和导出功能:

@Entry
@Component
struct SvgEditorApp {
  // 编辑器状态
  @State elements: EditorElement[] = [];
  @State selectedId: string = '';
  @State currentTool: EditorTool = EditorTool.SELECT;
  @State currentColor: string = '#333333';
  @State strokeWidth: number = 2;
  @State layers: LayerInfo[] = [{ id: 'layer_1', name: '图层1', visible: true }];
  @State activeLayerId: string = 'layer_1';
  @State showLayerPanel: boolean = false;

  // 拖拽状态
  @State dragStartX: number = 0;
  @State dragStartY: number = 0;
  @State isDragging: boolean = false;

  // 图层管理器
  private layerManager: LayerManager = new LayerManager();
  // 序列化器
  private serializer: SvgSerializer = new SvgSerializer(400, 500);
  // 撤销栈
  private undoStack: EditorElement[][] = [];
  private redoStack: EditorElement[][] = [];

  aboutToAppear(): void {
    this.layerManager.createLayer('图层1');
  }

  build() {
    Column() {
      // 顶部工具栏
      this.TopToolbar()

      // 主编辑区
      Row() {
        // 左侧工具面板
        this.ToolPanel()

        // 画布区域
        this.CanvasArea()
      }
      .layoutWeight(1)

      // 底部状态栏
      this.StatusBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#2c2c2c')
  }

  // 顶部工具栏
  @Builder
  TopToolbar() {
    Row() {
      // 撤销/重做
      Row({ space: 4 }) {
        Button('↩')
          .fontSize(16)
          .fontColor('#cccccc')
          .backgroundColor(Color.Transparent)
          .onClick(() => this.undo())

        Button('↪')
          .fontSize(16)
          .fontColor('#cccccc')
          .backgroundColor(Color.Transparent)
          .onClick(() => this.redo())
      }

      Blank()

      // 图层面板切换
      Button('图层')
        .fontSize(13)
        .fontColor('#cccccc')
        .backgroundColor(this.showLayerPanel ? '#555555' : Color.Transparent)
        .onClick(() => { this.showLayerPanel = !this.showLayerPanel; })

      // 导出按钮
      Button('导出SVG')
        .fontSize(13)
        .fontColor('#ffffff')
        .backgroundColor('#27ae60')
        .borderRadius(4)
        .onClick(() => this.exportSvg())
    }
    .width('100%')
    .height(44)
    .padding({ left: 12, right: 12 })
    .backgroundColor('#3c3c3c')
  }

  // 左侧工具面板
  @Builder
  ToolPanel() {
    Column({ space: 8 }) {
      // 选择工具
      this.ToolButton('⬚', EditorTool.SELECT)
      // 矩形工具
      this.ToolButton('□', EditorTool.RECT)
      // 圆形工具
      this.ToolButton('○', EditorTool.CIRCLE)
      // 路径工具
      this.ToolButton('✎', EditorTool.PATH)
      // 线条工具
      this.ToolButton('╱', EditorTool.LINE)

      // 分隔线
      Divider().color('#555555')

      // 颜色选择
      Column({ space: 4 }) {
        Text('填充')
          .fontSize(10)
          .fontColor('#999999')
        Stack() {
          Rect()
            .width(32)
            .height(32)
            .fill(this.currentColor)
            .border({ width: 1, color: '#666666' })
        }
        .onClick(() => {
          // 简化版颜色选择
          const colors = ['#333333', '#e74c3c', '#3498db', '#2ecc71', '#f1c40f', '#9b59b6'];
          const idx = colors.indexOf(this.currentColor);
          this.currentColor = colors[(idx + 1) % colors.length];
        })
      }
      .margin({ top: 8 })

      // 描边宽度
      Column({ space: 4 }) {
        Text('描边')
          .fontSize(10)
          .fontColor('#999999')
        Text(`${this.strokeWidth}px`)
          .fontSize(12)
          .fontColor('#cccccc')
        Slider({
          value: this.strokeWidth,
          min: 1,
          max: 10,
          step: 1,
          style: SliderStyle.InSet
        })
          .width(40)
          .onChange((value: number) => {
            this.strokeWidth = value;
          })
      }
      .margin({ top: 8 })
    }
    .width(56)
    .padding(8)
    .backgroundColor('#353535')
    .alignItems(HorizontalAlign.Center)
  }

  // 工具按钮
  @Builder
  ToolButton(icon: string, tool: EditorTool) {
    Stack() {
      Text(icon)
        .fontSize(20)
        .fontColor(this.currentTool === tool ? '#ffffff' : '#aaaaaa')
    }
    .width(40)
    .height(40)
    .borderRadius(8)
    .backgroundColor(this.currentTool === tool ? '#3498db' : Color.Transparent)
    .onClick(() => { this.currentTool = tool; })
  }

  // 画布区域
  @Builder
  CanvasArea() {
    Stack() {
      // SVG画布
      Svg({ width: '100%', height: '100%' }) {
        // 画布背景
        Rect()
          .width('100%')
          .height('100%')
          .fill('#ffffff')

        // 网格
        G() {
          ForEach(this.generateGrid(), (line: GridLineData) => {
            Line()
              .x1(line.x1).y1(line.y1)
              .x2(line.x2).y2(line.y2)
              .stroke('#f0f0f0')
              .strokeWidth(0.5)
          }
          )
        }

        // 绘制所有元素
        ForEach(this.elements, (element: EditorElement) => {
          if (element.type === 'rect') {
            Rect()
              .x(element.x)
              .y(element.y)
              .width(element.width)
              .height(element.height)
              .fill(element.fill)
              .stroke(element.stroke)
              .strokeWidth(element.strokeWidth)
              .rx(2)
              .ry(2)
              .opacity(element.opacity)
          } else if (element.type === 'circle') {
            Circle()
              .cx(element.x + (element.width || 0) / 2)
              .cy(element.y + (element.height || 0) / 2)
              .r(Math.min(element.width || 0, element.height || 0) / 2)
              .fill(element.fill)
              .stroke(element.stroke)
              .strokeWidth(element.strokeWidth)
              .opacity(element.opacity)
          } else if (element.type === 'line') {
            Line()
              .x1(element.x)
              .y1(element.y)
              .x2(element.x + (element.width || 0))
              .y2(element.y + (element.height || 0))
              .stroke(element.fill)
              .strokeWidth(element.strokeWidth)
              .strokeLineCap(LineCapStyle.Round)
          }
        }
        )

        // 选中元素的边框和控制点
        if (this.selectedId !== '') {
          this.SelectionOverlay()
        }
      }
      // 画布手势
      .gesture(
        PanGesture({ fingers: 1, distance: 1 })
          .onActionStart((event: GestureEvent) => {
            this.onCanvasTouchStart(event);
          })
          .onActionUpdate((event: GestureEvent) => {
            this.onCanvasTouchMove(event);
          })
          .onActionEnd(() => {
            this.onCanvasTouchEnd();
          })
      )

      // 图层面板(浮层)
      if (this.showLayerPanel) {
        this.LayerPanel()
      }
    }
    .layoutWeight(1)
  }

  // 选中元素的边框和控制点
  @Builder
  SelectionOverlay() {
    const selected = this.elements.find(e => e.id === this.selectedId);
    if (!selected) return;

    // 选中边框
    Rect()
      .x(selected.x - 2)
      .y(selected.y - 2)
      .width((selected.width || 0) + 4)
      .height((selected.height || 0) + 4)
      .fill('none')
      .stroke('#3498db')
      .strokeWidth(1.5)
      .strokeDashArray('4,3')

    // 四角控制点
    ForEach([
      { x: selected.x - 4, y: selected.y - 4 },                           // 左上
      { x: selected.x + (selected.width || 0) - 4, y: selected.y - 4 },   // 右上
      { x: selected.x - 4, y: selected.y + (selected.height || 0) - 4 },  // 左下
      { x: selected.x + (selected.width || 0) - 4, y: selected.y + (selected.height || 0) - 4 }  // 右下
    ], (point: { x: number; y: number }) => {
      Rect()
        .x(point.x)
        .y(point.y)
        .width(8)
        .height(8)
        .fill('#ffffff')
        .stroke('#3498db')
        .strokeWidth(1.5)
    }
    )
  }

  // 图层面板
  @Builder
  LayerPanel() {
    Column() {
      Text('图层管理')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#ffffff')
        .margin({ bottom: 8 })

      ForEach(this.layers, (layer: LayerInfo, index: number) => {
        Row() {
          Text(layer.visible ? '👁' : '—')
            .fontSize(14)
            .fontColor('#cccccc')
            .onClick(() => {
              this.layers[index].visible = !this.layers[index].visible;
            })

          Text(layer.name)
            .fontSize(13)
            .fontColor(this.activeLayerId === layer.id ? '#3498db' : '#cccccc')
            .margin({ left: 8 })
            .onClick(() => {
              this.activeLayerId = layer.id;
            })
        }
        .width('100%')
        .padding(8)
        .backgroundColor(this.activeLayerId === layer.id ? '#444444' : Color.Transparent)
        .borderRadius(4)
      }
      )

      // 添加图层按钮
      Button('+ 新建图层')
        .fontSize(12)
        .fontColor('#3498db')
        .backgroundColor(Color.Transparent)
        .margin({ top: 8 })
        .onClick(() => {
          const newLayer: LayerInfo = {
            id: `layer_${Date.now()}`,
            name: `图层${this.layers.length + 1}`,
            visible: true
          };
          this.layers.push(newLayer);
          this.activeLayerId = newLayer.id;
        })
    }
    .width(160)
    .padding(12)
    .backgroundColor('#3c3c3c')
    .borderRadius(8)
    .shadow({ radius: 8, color: '#40000000' })
    .position({ x: '60%', y: 10 })
  }

  // 底部状态栏
  @Builder
  StatusBar() {
    Row() {
      Text(`元素: ${this.elements.length}`)
        .fontSize(11)
        .fontColor('#888888')
      Text(` | `)
        .fontSize(11)
        .fontColor('#555555')
      Text(`工具: ${this.getToolName(this.currentTool)}`)
        .fontSize(11)
        .fontColor('#888888')
      if (this.selectedId !== '') {
        Text(` | 已选中`)
          .fontSize(11)
          .fontColor('#3498db')
      }
    }
    .width('100%')
    .height(28)
    .padding({ left: 12 })
    .backgroundColor('#3c3c3c')
  }

  // 画布触摸开始
  private onCanvasTouchStart(event: GestureEvent): void {
    const x = event.fingerList[0].localX;
    const y = event.fingerList[0].localY;

    if (this.currentTool === EditorTool.SELECT) {
      // 选择模式:检查是否点击了某个元素
      this.selectedId = '';
      for (let i = this.elements.length - 1; i >= 0; i--) {
        const el = this.elements[i];
        if (x >= el.x && x <= el.x + (el.width || 0) &&
            y >= el.y && y <= el.y + (el.height || 0)) {
          this.selectedId = el.id;
          this.isDragging = true;
          this.dragStartX = x - el.x;
          this.dragStartY = y - el.y;
          break;
        }
      }
    } else {
      // 绘制模式:开始创建新元素
      this.isDragging = true;
      this.dragStartX = x;
      this.dragStartY = y;

      const newElement: EditorElement = {
        id: `elem_${Date.now()}`,
        type: this.currentTool as string,
        x: x,
        y: y,
        width: 0,
        height: 0,
        fill: this.currentTool === EditorTool.LINE ? 'none' : this.currentColor,
        stroke: this.currentTool === EditorTool.LINE ? this.currentColor : 'none',
        strokeWidth: this.strokeWidth,
        opacity: 1.0,
        layerId: this.activeLayerId
      };
      this.elements.push(newElement);
      this.selectedId = newElement.id;
    }
  }

  // 画布触摸移动
  private onCanvasTouchMove(event: GestureEvent): void {
    if (!this.isDragging) return;
    const x = event.fingerList[0].localX;
    const y = event.fingerList[0].localY;

    const idx = this.elements.findIndex(e => e.id === this.selectedId);
    if (idx < 0) return;

    if (this.currentTool === EditorTool.SELECT) {
      // 选择模式:拖动元素
      this.elements[idx].x = x - this.dragStartX;
      this.elements[idx].y = y - this.dragStartY;
    } else {
      // 绘制模式:调整元素尺寸
      this.elements[idx].width = Math.abs(x - this.dragStartX);
      this.elements[idx].height = Math.abs(y - this.dragStartY);
      this.elements[idx].x = Math.min(x, this.dragStartX);
      this.elements[idx].y = Math.min(y, this.dragStartY);
    }
  }

  // 画布触摸结束
  private onCanvasTouchEnd(): void {
    this.isDragging = false;
    // 保存撤销快照
    this.undoStack.push(JSON.parse(JSON.stringify(this.elements)));
    this.redoStack = [];
  }

  // 撤销
  private undo(): void {
    if (this.undoStack.length === 0) return;
    this.redoStack.push(JSON.parse(JSON.stringify(this.elements)));
    this.elements = this.undoStack.pop()!;
    this.selectedId = '';
  }

  // 重做
  private redo(): void {
    if (this.redoStack.length === 0) return;
    this.undoStack.push(JSON.parse(JSON.stringify(this.elements)));
    this.elements = this.redoStack.pop()!;
    this.selectedId = '';
  }

  // 导出SVG
  private exportSvg(): void {
    // 构建SVG字符串
    let svg = `<?xml version="1.0" encoding="UTF-8"?>\n`;
    svg += `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="500" viewBox="0 0 400 500">\n`;

    for (const element of this.elements) {
      if (element.type === 'rect') {
        svg += `  <rect x="${element.x.toFixed(1)}" y="${element.y.toFixed(1)}" ` +
               `width="${element.width.toFixed(1)}" height="${element.height.toFixed(1)}" ` +
               `fill="${element.fill}" stroke="${element.stroke}" ` +
               `stroke-width="${element.strokeWidth}" />\n`;
      } else if (element.type === 'circle') {
        const r = Math.min(element.width, element.height) / 2;
        svg += `  <circle cx="${(element.x + element.width / 2).toFixed(1)}" ` +
               `cy="${(element.y + element.height / 2).toFixed(1)}" r="${r.toFixed(1)}" ` +
               `fill="${element.fill}" />\n`;
      } else if (element.type === 'line') {
        svg += `  <line x1="${element.x.toFixed(1)}" y1="${element.y.toFixed(1)}" ` +
               `x2="${(element.x + element.width).toFixed(1)}" y2="${(element.y + element.height).toFixed(1)}" ` +
               `stroke="${element.stroke}" stroke-width="${element.strokeWidth}" />\n`;
      }
    }

    svg += `</svg>`;

    // 保存到文件
    try {
      const context = getContext(this);
      const filesDir = context.filesDir;
      const filePath = `${filesDir}/export_${Date.now()}.svg`;
      // 写入文件
      const file = fs.openSync(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
      fs.writeSync(file.fd, svg);
      fs.closeSync(file);
      promptAction.showToast({ message: `已导出: ${filePath}` });
    } catch (err) {
      promptAction.showToast({ message: '导出失败' });
    }
  }

  // 生成网格线
  private generateGrid(): GridLineData[] {
    const lines: GridLineData[] = [];
    const step = 20;
    for (let x = 0; x <= 400; x += step) {
      lines.push({ x1: x, y1: 0, x2: x, y2: 500 });
    }
    for (let y = 0; y <= 500; y += step) {
      lines.push({ x1: 0, y1: y, x2: 400, y2: y });
    }
    return lines;
  }

  // 获取工具名称
  private getToolName(tool: EditorTool): string {
    const names: Record<number, string> = {
      [EditorTool.SELECT]: '选择',
      [EditorTool.RECT]: '矩形',
      [EditorTool.CIRCLE]: '圆形',
      [EditorTool.PATH]: '路径',
      [EditorTool.LINE]: '线条'
    };
    return names[tool] || '未知';
  }
}

enum EditorTool {
  SELECT = 0,
  RECT = 1,
  CIRCLE = 2,
  PATH = 3,
  LINE = 4
}

interface EditorElement {
  id: string;
  type: string;
  x: number;
  y: number;
  width: number;
  height: number;
  fill: string;
  stroke: string;
  strokeWidth: number;
  opacity: number;
  layerId: string;
}

interface LayerInfo {
  id: string;
  name: string;
  visible: boolean;
}

interface GridLineData {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
}

四、踩坑与注意事项

坑点1:SVG元素的点击检测精度

SVG元素使用包围盒(Bounding Box)做点击检测,对于非矩形元素(如圆形、三角形、星形),包围盒会包含大量"空白区域",导致点击判断不准确。解决方案:对圆形使用距离公式判断,对路径使用射线法(Ray Casting)判断点是否在多边形内部。精度要求不高时,包围盒够用。

坑点2:变换矩阵的累积误差

多次旋转和缩放操作会导致变换矩阵的数值精度逐渐丢失,最终出现"越拖越歪"的现象。解决方案:定期对矩阵进行归一化处理,或者将变换拆解为独立的translate/rotate/scale参数存储,只在渲染时组合为矩阵。

坑点3:撤销重做的内存占用

每次操作都保存完整的元素列表快照,在元素数量多时内存消耗巨大。解决方案:使用差异快照(只保存变化的部分),或者限制撤销栈深度(如50步)。更高级的做法是使用命令模式,只记录操作本身而非整个状态。

坑点4:SVG导出的坐标精度

导出SVG时,浮点数坐标如果保留过多小数位,会显著增加文件体积。解决方案:坐标保留1-2位小数,变换矩阵参数保留4位小数。对于路径数据,可以使用路径简化算法减少冗余点。

坑点5:图层拖拽排序的实现难度

图层面板中的拖拽排序看似简单,实则涉及复杂的手势处理和动画效果。解决方案:使用ArkUI的 List 组件配合 onDragStart/onDrop 事件,或者使用 @State 数组配合 animateTo 实现平滑的位移动画。

坑点6:路径编辑的贝塞尔曲线控制点

编辑贝塞尔曲线时,控制点的位置需要实时计算和更新。拖动一个控制点会影响曲线形状,但不应影响相邻曲线段的控制点。解决方案:维护路径命令与控制点的映射关系,拖动时只更新当前命令的参数,然后重新生成路径字符串。


五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
SVG手势 PanGesture基础支持 新增DragGesture拖拽手势 图层排序可直接用DragGesture
文件写入 fs.writeSync同步写入 推荐fs.writeAsync异步写入 大文件导出避免阻塞UI
SVG Path 基础路径支持 新增pathLength属性 可实现路径描边动画
组件拖拽 手动实现 新增onDragStart/onDrop 图层拖拽排序更简单
文件选择器 手动实现 新增PhotoViewPicker 导出时可选择保存位置

行为变更

  • SVG渲染精度提升:6.0中SVG坐标计算使用双精度浮点数,变换矩阵的累积误差显著降低
  • DragGesture新增:6.0新增DragGesture手势类型,内置拖拽阴影效果,适合图层排序等场景
  • 文件操作异步化:6.0推荐使用异步文件操作API,同步操作在大文件场景下可能导致ANR

适配代码

// HarmonyOS 6适配:使用DragGesture实现图层拖拽排序
@Component
struct LayerPanelV6 {
  @State layers: LayerInfo[] = [
    { id: '1', name: '图层1', visible: true },
    { id: '2', name: '图层2', visible: true },
    { id: '3', name: '图层3', visible: true }
  ];

  build() {
    List() {
      ForEach(this.layers, (layer: LayerInfo, index: number) => {
        ListItem() {
          Row() {
            Text(layer.name)
              .fontSize(14)
              .fontColor('#cccccc')
          }
          .width('100%')
          .height(40)
          .padding({ left: 12 })
          .backgroundColor('#444444')
          .borderRadius(4)
        }
        .margin({ bottom: 4 })
        // 6.0新增拖拽支持
        .onDragStart(() => {
          return { data: layer.id };
        })
        .onDrop((event: DragEvent) => {
          const draggedId = event.getData().getData();
          const draggedIdx = this.layers.findIndex(l => l.id === draggedId);
          if (draggedIdx >= 0 && draggedIdx !== index) {
            const dragged = this.layers.splice(draggedIdx, 1)[0];
            this.layers.splice(index, 0, dragged);
          }
        })
      }
      )
    }
    .width(180)
    .padding(8)
  }
}

// HarmonyOS 6适配:异步导出SVG文件
private async exportSvgV6(): Promise<void> {
  const svgContent = this.buildSvgString();
  try {
    const context = getContext(this);
    const filesDir = context.filesDir;
    const filePath = `${filesDir}/export_${Date.now()}.svg`;

    // 6.0推荐异步写入
    const file = await fs.openAsync(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
    await fs.writeAsync(file.fd, svgContent);
    await fs.closeAsync(file.fd);

    promptAction.showToast({ message: `已导出到: ${filePath}` });
  } catch (err) {
    promptAction.showToast({ message: '导出失败: ' + (err as Error).message });
  }
}

六、总结

维度 评价
学习难度 ⭐⭐⭐⭐⭐
使用频率 ⭐⭐⭐
重要程度 ⭐⭐⭐⭐

SVG编辑器是一个"麻雀虽小,五脏俱全"的项目。它涉及数据建模、交互设计、图形渲染、文件I/O等多个技术领域,是综合能力的试金石。本文我们从四个核心维度构建了完整的编辑器:

架构设计是地基。MVC模式将数据、视图、控制逻辑清晰分离,让代码可维护、可扩展。文档对象模型是核心数据结构,每个SVG元素都是独立的对象,拥有自己的属性、变换和序列化方法。

路径编辑是灵魂。贝塞尔曲线是矢量图形的基石,理解控制点的工作原理是编辑器开发的基本功。从二次到三次,从直线到弧线,路径编辑的每一个细节都影响着用户体验。

变换操作是骨架。平移、旋转、缩放看似简单,但变换矩阵的数学原理和累积误差问题不容忽视。记住:变换顺序很重要,矩阵精度要维护。

图层管理与导出是收尾。图层让复杂图形变得有序可控,导出序列化让创作成果得以保存和分享。两者看似是"辅助功能",却是编辑器从"玩具"升级为"工具"的关键。

这个编辑器还有很多可以扩展的方向:文字工具、渐变填充、布尔运算、对齐分布、组件库……每一个功能都是对现有架构的自然延伸。掌握了核心原理,剩下的就是产品设计和细节打磨了。正如那句老话:架构决定上限,细节决定体验。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。