HarmonyOS APP开发:SVG编辑器与矢量绘图工具
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元素都是独立的对象,拥有自己的属性、变换和序列化方法。
路径编辑是灵魂。贝塞尔曲线是矢量图形的基石,理解控制点的工作原理是编辑器开发的基本功。从二次到三次,从直线到弧线,路径编辑的每一个细节都影响着用户体验。
变换操作是骨架。平移、旋转、缩放看似简单,但变换矩阵的数学原理和累积误差问题不容忽视。记住:变换顺序很重要,矩阵精度要维护。
图层管理与导出是收尾。图层让复杂图形变得有序可控,导出序列化让创作成果得以保存和分享。两者看似是"辅助功能",却是编辑器从"玩具"升级为"工具"的关键。
这个编辑器还有很多可以扩展的方向:文字工具、渐变填充、布尔运算、对齐分布、组件库……每一个功能都是对现有架构的自然延伸。掌握了核心原理,剩下的就是产品设计和细节打磨了。正如那句老话:架构决定上限,细节决定体验。
- 点赞
- 收藏
- 关注作者
评论(0)