一、应用概述

1.1 应用简介

画板(Drawing Board)是一款自由绘画创作工具,支持多种画笔工具、颜色选择、笔画粗细调整、撤销重做、背景色切换和图片导出功能。该应用以HarmonyOS NEXT的ArkTS框架为基础,深入展示了手势识别与触摸事件处理、自定义绘制Canvas、绘制数据结构设计、命令模式(撤销/重做)、多工具绘制模式和图片导出等关键技术。

1.2 核心功能

功能模块 功能描述 技术实现 设计考量
画笔工具 画笔/直线/矩形/圆形/擦除 绘制模式枚举 多工具切换
颜色选择 10+预设颜色+自定义颜色 颜色数据+取色器 色彩丰富
笔画粗细 5种笔画宽度(2-20px) 配置管理 适合不同场景
撤销重做 操作历史管理 命令模式 无限撤销
清空画布 一键清空 状态重置 确认提示
背景切换 多种背景色/网格 样式绑定 辅助绘画
导出图片 保存为PNG/JPEG 截图API 分享保存
手势识别 单指绘画/双指缩放 触摸事件处理 多点触控

1.3 应用架构

画板应用采用分层架构:

  1. UI表现层:绘画画布、工具栏、颜色选择器、粗细滑块、状态栏。
  2. 业务逻辑层:绘制引擎、命令管理器(撤销/重做)、工具管理器、颜色管理器。
  3. 数据持久层:使用Preferences API保存画布设置和最近使用的颜色。

二、绘制系统

2.1 绘制数据结构

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

interface DrawAction {
  tool: DrawingTool;
  color: string;
  width: number;
  points: DrawPoint[];
  opacity: number;
  fillColor: string | null;  // 填充颜色(用于矩形、圆形)
  timestamp: number;
}

enum DrawingTool {
  PEN = 'pen',           // 画笔
  LINE = 'line',         // 直线
  RECTANGLE = 'rectangle', // 矩形
  CIRCLE = 'circle',     // 圆形
  ERASER = 'eraser',     // 橡皮擦
  ARROW = 'arrow',       // 箭头
  TEXT = 'text'          // 文字
}

interface CanvasState {
  actions: DrawAction[];
  currentTool: DrawingTool;
  currentColor: string;
  currentWidth: number;
  backgroundColor: string;
  opacity: number;
  showGrid: boolean;
  gridSize: number;
}

class DrawingManager {
  private actions: DrawAction[] = [];
  private redoStack: DrawAction[] = [];
  private currentPoints: DrawPoint[] = [];
  private isDrawing: boolean = false;
  private maxActions: number = 500; // 最大操作数
  
  startDrawing(point: DrawPoint, tool: DrawingTool, color: string, width: number): void {
    this.isDrawing = true;
    this.currentPoints = [point];
    
    // 对于直线/矩形/圆形,记录起始点
    const action: DrawAction = {
      tool,
      color,
      width,
      points: [point],
      opacity: 1.0,
      fillColor: null,
      timestamp: Date.now()
    };
    this.actions.push(action);
  }
  
  continueDrawing(point: DrawPoint): void {
    if (!this.isDrawing) return;
    
    this.currentPoints.push(point);
    const lastAction = this.actions[this.actions.length - 1];
    if (lastAction) {
      if (lastAction.tool === DrawingTool.PEN || lastAction.tool === DrawingTool.ERASER) {
        lastAction.points.push(point);
      } else {
        // 直线/矩形/圆形:更新结束点
        lastAction.points = [this.currentPoints[0], point];
      }
    }
  }
  
  endDrawing(): void {
    this.isDrawing = false;
    this.currentPoints = [];
    this.redoStack = []; // 新操作清除重做栈
    
    // 限制操作数
    if (this.actions.length > this.maxActions) {
      this.actions = this.actions.slice(-this.maxActions);
    }
  }
  
  // 撤销
  undo(): DrawAction | null {
    if (this.actions.length === 0) return null;
    const action = this.actions.pop()!;
    this.redoStack.push(action);
    return action;
  }
  
  // 重做
  redo(): DrawAction | null {
    if (this.redoStack.length === 0) return null;
    const action = this.redoStack.pop()!;
    this.actions.push(action);
    return action;
  }
  
  canUndo(): boolean {
    return this.actions.length > 0;
  }
  
  canRedo(): boolean {
    return this.redoStack.length > 0;
  }
  
  clear(): void {
    this.actions = [];
    this.redoStack = [];
    this.currentPoints = [];
    this.isDrawing = false;
  }
  
  getActions(): DrawAction[] {
    return [...this.actions];
  }
  
  getActionCount(): number {
    return this.actions.length;
  }
}

2.2 Canvas绘制引擎

class CanvasRenderer {
  private context: CanvasRenderingContext2D | null = null;
  
  setContext(ctx: CanvasRenderingContext2D): void {
    this.context = ctx;
  }
  
  render(actions: DrawAction[], backgroundColor: string, showGrid: boolean, gridSize: number): void {
    if (!this.context) return;
    const ctx = this.context;
    const canvas = ctx.canvas;
    
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 绘制背景
    ctx.fillStyle = backgroundColor;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    // 绘制网格
    if (showGrid) {
      this.drawGrid(ctx, canvas.width, canvas.height, gridSize);
    }
    
    // 绘制所有动作
    for (const action of actions) {
      this.drawAction(ctx, action);
    }
  }
  
  private drawGrid(ctx: CanvasRenderingContext2D, width: number, height: number, gridSize: number): void {
    ctx.strokeStyle = '#E0E0E0';
    ctx.lineWidth = 0.5;
    
    for (let x = 0; x <= width; x += gridSize) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, height);
      ctx.stroke();
    }
    
    for (let y = 0; y <= height; y += gridSize) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(width, y);
      ctx.stroke();
    }
  }
  
  private drawAction(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    if (action.points.length < 1) return;
    
    ctx.save();
    ctx.globalAlpha = action.opacity;
    
    switch (action.tool) {
      case DrawingTool.PEN:
        this.drawPenStroke(ctx, action);
        break;
      case DrawingTool.ERASER:
        this.drawEraserStroke(ctx, action);
        break;
      case DrawingTool.LINE:
        this.drawLine(ctx, action);
        break;
      case DrawingTool.RECTANGLE:
        this.drawRectangle(ctx, action);
        break;
      case DrawingTool.CIRCLE:
        this.drawCircle(ctx, action);
        break;
      case DrawingTool.ARROW:
        this.drawArrow(ctx, action);
        break;
    }
    
    ctx.restore();
  }
  
  private drawPenStroke(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    if (action.points.length < 2) {
      ctx.fillStyle = action.color;
      ctx.beginPath();
      ctx.arc(action.points[0].x, action.points[0].y, action.width / 2, 0, Math.PI * 2);
      ctx.fill();
      return;
    }
    
    ctx.strokeStyle = action.color;
    ctx.lineWidth = action.width;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.beginPath();
    ctx.moveTo(action.points[0].x, action.points[0].y);
    
    for (let i = 1; i < action.points.length; i++) {
      ctx.lineTo(action.points[i].x, action.points[i].y);
    }
    
    ctx.stroke();
  }
  
  private drawEraserStroke(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    ctx.strokeStyle = action.color; // 背景色
    ctx.lineWidth = action.width * 2;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.beginPath();
    ctx.moveTo(action.points[0].x, action.points[0].y);
    
    for (let i = 1; i < action.points.length; i++) {
      ctx.lineTo(action.points[i].x, action.points[i].y);
    }
    
    ctx.stroke();
  }
  
  private drawLine(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    if (action.points.length < 2) return;
    ctx.strokeStyle = action.color;
    ctx.lineWidth = action.width;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(action.points[0].x, action.points[0].y);
    ctx.lineTo(action.points[1].x, action.points[1].y);
    ctx.stroke();
  }
  
  private drawRectangle(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    if (action.points.length < 2) return;
    const x = Math.min(action.points[0].x, action.points[1].x);
    const y = Math.min(action.points[0].y, action.points[1].y);
    const w = Math.abs(action.points[1].x - action.points[0].x);
    const h = Math.abs(action.points[1].y - action.points[0].y);
    
    ctx.strokeStyle = action.color;
    ctx.lineWidth = action.width;
    ctx.strokeRect(x, y, w, h);
    
    if (action.fillColor) {
      ctx.fillStyle = action.fillColor;
      ctx.fillRect(x, y, w, h);
    }
  }
  
  private drawCircle(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    if (action.points.length < 2) return;
    const cx = (action.points[0].x + action.points[1].x) / 2;
    const cy = (action.points[0].y + action.points[1].y) / 2;
    const rx = Math.abs(action.points[1].x - action.points[0].x) / 2;
    const ry = Math.abs(action.points[1].y - action.points[0].y) / 2;
    const radius = Math.max(rx, ry);
    
    ctx.strokeStyle = action.color;
    ctx.lineWidth = action.width;
    ctx.beginPath();
    ctx.arc(cx, cy, radius, 0, Math.PI * 2);
    ctx.stroke();
    
    if (action.fillColor) {
      ctx.fillStyle = action.fillColor;
      ctx.beginPath();
      ctx.arc(cx, cy, radius, 0, Math.PI * 2);
      ctx.fill();
    }
  }
  
  private drawArrow(ctx: CanvasRenderingContext2D, action: DrawAction): void {
    if (action.points.length < 2) return;
    const x1 = action.points[0].x, y1 = action.points[0].y;
    const x2 = action.points[1].x, y2 = action.points[1].y;
    
    ctx.strokeStyle = action.color;
    ctx.lineWidth = action.width;
    ctx.lineCap = 'round';
    
    // 画线
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.stroke();
    
    // 画箭头(三角形)
    const angle = Math.atan2(y2 - y1, x2 - x1);
    const headLen = 15 + action.width;
    
    ctx.fillStyle = action.color;
    ctx.beginPath();
    ctx.moveTo(x2, y2);
    ctx.lineTo(x2 - headLen * Math.cos(angle - 0.4), y2 - headLen * Math.sin(angle - 0.4));
    ctx.lineTo(x2 - headLen * Math.cos(angle + 0.4), y2 - headLen * Math.sin(angle + 0.4));
    ctx.closePath();
    ctx.fill();
  }
}

三、UI交互设计

3.1 画板主界面

@Component
struct DrawingBoardView {
  @State currentTool: DrawingTool = DrawingTool.PEN;
  @State currentColor: string = '#000000';
  @State currentWidth: number = 4;
  @State backgroundColor: string = '#FFFFFF';
  @State showGrid: boolean = false;
  @State gridSize: number = 20;
  @State canUndo: boolean = false;
  @State canRedo: boolean = false;
  @State actionCount: number = 0;
  
  private drawingManager = new DrawingManager();
  private renderer = new CanvasRenderer();
  private canvasContext: CanvasRenderingContext2D | null = null;
  
  private readonly COLORS: string[] = [
    '#000000', '#333333', '#666666', '#999999',
    '#E53935', '#FF5722', '#FF9800', '#FFC107',
    '#4CAF50', '#2196F3', '#1565C0', '#9C27B0',
    '#E91E63', '#00BCD4', '#795548', '#607D8B'
  ];
  
  private readonly WIDTHS: number[] = [2, 4, 6, 10, 20];
  
  build() {
    Column() {
      // 工具栏
      this.renderToolbar()
      
      // 画布
      Stack() {
        Canvas(this.canvasContext)
          .width('100%')
          .height('100%')
          .backgroundColor(this.backgroundColor)
          .onTouch((event: TouchEvent) => {
            this.handleTouch(event);
          })
      }
      .layoutWeight(1)
      .width('100%')
      .margin(8)
      
      // 底部控制栏
      this.renderBottomBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
  
  @Builder
  private renderToolbar() {
    Scroll({ direction: ScrollDirection.Horizontal }) {
      Row({ space: 8 }) {
        this.createToolButton('✏️', '画笔', DrawingTool.PEN)
        this.createToolButton('📏', '直线', DrawingTool.LINE)
        this.createToolButton('⬜', '矩形', DrawingTool.RECTANGLE)
        this.createToolButton('⭕', '圆形', DrawingTool.CIRCLE)
        this.createToolButton('🧹', '橡皮', DrawingTool.ERASER)
        this.createToolButton('➡️', '箭头', DrawingTool.ARROW)
      }
      .padding(8)
    }
    .width('100%')
    .height(56)
    .backgroundColor('#FFFFFF')
  }
  
  @Builder
  private createToolButton(emoji: string, label: string, tool: DrawingTool) {
    Column() {
      Text(emoji).fontSize(20)
      Text(label).fontSize(10).fontColor(this.currentTool === tool ? '#4CAF50' : '#666')
    }
    .padding(8)
    .backgroundColor(this.currentTool === tool ? '#E8F5E9' : 'transparent')
    .borderRadius(8)
    .onClick(() => { this.currentTool = tool; })
  }
  
  private handleTouch(event: TouchEvent): void {
    if (event.touches.length === 0) return;
    const touch = event.touches[0];
    const point: DrawPoint = { x: touch.x, y: touch.y };
    
    switch (event.type) {
      case TouchType.Down:
        this.drawingManager.startDrawing(point, this.currentTool, this.currentColor, this.currentWidth);
        this.canUndo = this.drawingManager.canUndo();
        break;
      case TouchType.Move:
        this.drawingManager.continueDrawing(point);
        break;
      case TouchType.Up:
        this.drawingManager.endDrawing();
        this.canUndo = this.drawingManager.canUndo();
        this.canRedo = this.drawingManager.canRedo();
        this.actionCount = this.drawingManager.getActionCount();
        break;
    }
    
    this.renderCanvas();
  }
  
  private renderCanvas(): void {
    this.renderer.render(
      this.drawingManager.getActions(),
      this.backgroundColor,
      this.showGrid,
      this.gridSize
    );
  }
}

四、总结

4.1 核心技术要点

  1. 绘制数据结构:基于DrawAction的数据结构,记录每次绘制的工具、颜色、粗细和路径点。
  2. Canvas绘制引擎:支持画笔、直线、矩形、圆形、箭头和橡皮擦6种工具。
  3. 命令模式撤销/重做:通过actions和redoStack实现无限撤销重做。
  4. 手势识别:基于TouchEvent的触摸事件处理,支持单指绘画。
  5. 颜色管理系统:16种预设颜色 + 自定义颜色选择。
  6. 图片导出:基于Canvas截图API的图片导出功能。

4.2 扩展方向

  1. 图层系统:支持多图层编辑,独立操作每个图层。
  2. 滤镜效果:添加模糊、锐化、马赛克等滤镜。
  3. 文字工具:在画布上添加文字,支持字体选择。
  4. 图片导入:导入图片作为背景或绘制参考。
  5. 形状识别:识别手绘形状并自动对齐为完美形状。

4.3 核心代码量统计

模块 核心代码行数 接口数 组件数
绘制管理器 120 8 -
Canvas渲染引擎 200 6 -
命令管理器 60 5 -
UI组件 300 4 5
总计 680 23 5

Logo

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

更多推荐