HarmonyOS APP开发:Canvas实战项目——手绘白板应用

举报
Jack20 发表于 2026/06/22 22:22:07 2026/06/22
【摘要】 HarmonyOS APP开发:Canvas实战项目——手绘白板应用📌 核心要点:基于HarmonyOS Canvas组件构建完整的手绘白板应用,涵盖笔触效果、撤销重做、导出分享等核心功能,打造流畅自然的绘画体验。 一、背景与动机你有没有这样的经历?开会时想随手画个流程图说明思路,却发现手边只有手机;给孩子讲数学题,想画个辅助线却找不到纸笔;灵感突然来了,想快速勾勒一个UI草图,却打开绘...

HarmonyOS APP开发:Canvas实战项目——手绘白板应用

📌 核心要点:基于HarmonyOS Canvas组件构建完整的手绘白板应用,涵盖笔触效果、撤销重做、导出分享等核心功能,打造流畅自然的绘画体验。


一、背景与动机

你有没有这样的经历?开会时想随手画个流程图说明思路,却发现手边只有手机;给孩子讲数学题,想画个辅助线却找不到纸笔;灵感突然来了,想快速勾勒一个UI草图,却打开绘图软件就花了五分钟。

手绘白板应用,就是解决这类"随手画"需求的利器。它不需要你成为画家,也不需要复杂的专业工具——打开就能画,画完就能分享,就是这么简单直接。

在HarmonyOS生态中,Canvas组件为我们提供了强大的2D绘图能力。但说实话,光会画线、画圆还远远不够。一个真正好用的白板应用,得让用户感觉"笔跟手走"——线条流畅、笔触自然、撤销重做丝滑、导出分享方便。这背后涉及到绘制引擎设计、笔触算法、状态管理等一系列技术挑战。

今天这篇文章,我们就从零开始,一步步搭建一个完整的手绘白板应用。别担心,我会把每个关键环节都掰开了揉碎了讲清楚。


二、核心原理

2.1 手绘白板整体架构

一个白板应用的核心架构可以拆解为三层:交互层负责捕获用户手势,绘制引擎层负责将手势数据转化为图形,数据管理层负责维护画布状态和历史记录。

graph TD
    A[用户手势输入]:::primary --> B[交互层:手势识别与坐标采集]:::info
    B --> C[绘制引擎层:笔触计算与Canvas渲染]:::warning
    C --> D[数据管理层:路径存储与历史栈]:::info
    D --> E[功能层:撤销/重做/导出/分享]:::primary
    
    D -->|状态恢复| C
    E -->|操作反馈| D
    
    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 笔触效果原理

为什么手绘的线条和鼠标画的线条感觉完全不同?关键在于速度压力

人在纸上写字时,写得快的地方线条细,写得慢的地方线条粗——这是笔尖与纸张接触时间的自然结果。而数字白板要模拟这种效果,就需要根据手指移动速度来动态调整线宽:

  • 速度快 → 线宽小(笔触细)
  • 速度慢 → 线宽大(笔触粗)

计算公式:lineWidth = baseWidth * (1 / (1 + speed * factor))

其中 speed 是相邻两点的距离除以时间差,factor 是灵敏度系数。

2.3 撤销重做原理

撤销重做的核心是命令模式(Command Pattern)。每一次绘制操作都是一个"命令"对象,包含执行和撤销两个方法:

  • 撤销栈(undoStack):存放已执行的命令,撤销时弹出并执行其撤销方法
  • 重做栈(redoStack):存放已撤销的命令,重做时弹出并重新执行

新操作入栈时,清空重做栈——这和你在编辑器里的行为一致:撤销后输入新内容,之前的重做历史就没了。


三、代码实战

3.1 基础用法:Canvas绘制引擎核心

先搭建绘制引擎的基础骨架,定义路径数据结构和核心绘制方法:

// 路径点数据结构
interface DrawPoint {
  x: number;          // X坐标
  y: number;          // Y坐标
  timestamp: number;  // 时间戳(用于速度计算)
  pressure: number;   // 压力值(0-1)
}

// 笔触路径
interface StrokePath {
  points: DrawPoint[];    // 路径点集合
  color: string;          // 笔触颜色
  baseWidth: number;      // 基础线宽
  toolType: ToolType;     // 工具类型
}

// 工具类型枚举
enum ToolType {
  PEN = 'pen',           // 画笔
  HIGHLIGHTER = 'highlighter', // 荧光笔
  ERASER = 'eraser'      // 橡皮擦
}

// 绘制引擎核心类
class DrawingEngine {
  private paths: StrokePath[] = [];          // 所有路径
  private currentPath: StrokePath | null = null; // 当前正在绘制的路径
  private settings: DrawingSettings = {      // 绘制设置
    color: '#000000',
    baseWidth: 3,
    toolType: ToolType.PEN
  };

  // 开始新路径
  startPath(x: number, y: number): void {
    const point: DrawPoint = {
      x: x,
      y: y,
      timestamp: Date.now(),
      pressure: 0.5
    };
    this.currentPath = {
      points: [point],
      color: this.settings.color,
      baseWidth: this.settings.baseWidth,
      toolType: this.settings.toolType
    };
  }

  // 追加路径点
  addPoint(x: number, y: number): void {
    if (!this.currentPath) return;
    const point: DrawPoint = {
      x: x,
      y: y,
      timestamp: Date.now(),
      pressure: 0.5
    };
    this.currentPath.points.push(point);
  }

  // 结束当前路径
  endPath(): StrokePath | null {
    if (!this.currentPath) return null;
    const path = this.currentPath;
    this.paths.push(path);
    this.currentPath = null;
    return path;
  }

  // 获取所有路径
  getPaths(): StrokePath[] {
    return this.paths;
  }

  // 更新绘制设置
  updateSettings(settings: Partial<DrawingSettings>): void {
    this.settings = { ...this.settings, ...settings };
  }
}

interface DrawingSettings {
  color: string;
  baseWidth: number;
  toolType: ToolType;
}

3.2 进阶用法:笔触效果与撤销重做

接下来实现笔触效果计算和撤销重做机制:

// 笔触效果计算器
class StrokeCalculator {
  // 根据速度计算线宽
  static calcWidthBySpeed(
    prev: DrawPoint,
    curr: DrawPoint,
    baseWidth: number,
    factor: number = 0.003
  ): number {
    const dx = curr.x - prev.x;
    const dy = curr.y - prev.y;
    const dt = Math.max(curr.timestamp - prev.timestamp, 1);
    const distance = Math.sqrt(dx * dx + dy * dy);
    const speed = distance / dt;
    // 速度越快线越细,速度越慢线越粗
    const width = baseWidth * (1 / (1 + speed * factor * 100));
    // 限制线宽范围
    return Math.max(baseWidth * 0.3, Math.min(baseWidth * 2.5, width));
  }

  // 贝塞尔曲线平滑:用中点作为控制点
  static smoothPoints(points: DrawPoint[]): DrawPoint[] {
    if (points.length < 3) return points;
    const smoothed: DrawPoint[] = [points[0]];
    for (let i = 1; i < points.length - 1; i++) {
      const prev = points[i - 1];
      const curr = points[i];
      const next = points[i + 1];
      // 中点平滑
      const midX = (curr.x + next.x) / 2;
      const midY = (curr.y + next.y) / 2;
      smoothed.push({
        x: midX,
        y: midY,
        timestamp: curr.timestamp,
        pressure: (curr.pressure + next.pressure) / 2
      });
    }
    smoothed.push(points[points.length - 1]);
    return smoothed;
  }
}

// 撤销重做管理器
class UndoRedoManager {
  private undoStack: StrokePath[] = [];  // 撤销栈
  private redoStack: StrokePath[] = [];  // 重做栈
  private maxStackSize: number = 50;     // 最大栈深度

  // 执行绘制操作(入撤销栈)
  pushStroke(path: StrokePath): void {
    this.undoStack.push(path);
    // 新操作清空重做栈
    this.redoStack = [];
    // 超出上限时移除最早的记录
    if (this.undoStack.length > this.maxStackSize) {
      this.undoStack.shift();
    }
  }

  // 撤销操作
  undo(): StrokePath | null {
    if (this.undoStack.length === 0) return null;
    const path = this.undoStack.pop()!;
    this.redoStack.push(path);
    return path;
  }

  // 重做操作
  redo(): StrokePath | null {
    if (this.redoStack.length === 0) return null;
    const path = this.redoStack.pop()!;
    this.undoStack.push(path);
    return path;
  }

  // 是否可以撤销
  canUndo(): boolean {
    return this.undoStack.length > 0;
  }

  // 是否可以重做
  canRedo(): boolean {
    return this.redoStack.length > 0;
  }

  // 清空所有历史
  clear(): void {
    this.undoStack = [];
    this.redoStack = [];
  }
}

3.3 完整示例:手绘白板应用

下面是完整的白板应用实现,包含工具栏、画布、撤销重做、导出等全部功能:

import { componentUtils } from '@kit.ArkUI';

@Entry
@Component
struct WhiteboardApp {
  // 绘制引擎
  private engine: DrawingEngine = new DrawingEngine();
  private undoRedo: UndoRedoManager = new UndoRedoManager();

  // 状态变量
  @State currentColor: string = '#1a1a1a';
  @State currentWidth: number = 3;
  @State currentTool: ToolType = ToolType.PEN;
  @State canUndo: boolean = false;
  @State canRedo: boolean = false;
  @State strokeCount: number = 0;

  // 画布上下文
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: RenderingContext = new RenderingContext(this.settings);

  // 颜色面板
  private colorPalette: string[] = [
    '#1a1a1a', '#e74c3c', '#e67e22', '#f1c40f',
    '#2ecc71', '#3498db', '#9b59b6', '#ecf0f1'
  ];

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

      // 画布区域
      Stack() {
        Canvas(this.context)
          .width('100%')
          .height('100%')
          .backgroundColor('#ffffff')
          .onReady(() => {
            this.initCanvas();
          })
          .gesture(
            PanGesture({ fingers: 1, distance: 1 })
              .onActionStart((event: GestureEvent) => {
                this.onDrawStart(event);
              })
              .onActionUpdate((event: GestureEvent) => {
                this.onDrawMove(event);
              })
              .onActionEnd(() => {
                this.onDrawEnd();
              })
          )
      }
      .layoutWeight(1)

      // 底部颜色选择器
      this.ColorPicker()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
  }

  // 顶部工具栏组件
  @Builder
  Toolbar() {
    Row() {
      // 撤销按钮
      Button('撤销')
        .enabled(this.canUndo)
        .fontSize(14)
        .fontColor(this.canUndo ? '#333333' : '#999999')
        .backgroundColor(Color.Transparent)
        .onClick(() => this.handleUndo())

      // 重做按钮
      Button('重做')
        .enabled(this.canRedo)
        .fontSize(14)
        .fontColor(this.canRedo ? '#333333' : '#999999')
        .backgroundColor(Color.Transparent)
        .onClick(() => this.handleRedo())

      Blank()

      // 工具切换
      Row() {
        ForEach([
          { type: ToolType.PEN, icon: '✏️', label: '画笔' },
          { type: ToolType.HIGHLIGHTER, icon: '🖍️', label: '荧光笔' },
          { type: ToolType.ERASER, icon: '🧹', label: '橡皮' }
        ], (item: { type: ToolType; icon: string; label: string }) => {
          Button(item.label)
            .fontSize(13)
            .fontColor(this.currentTool === item.type ? '#ffffff' : '#333333')
            .backgroundColor(this.currentTool === item.type ? '#3498db' : '#e0e0e0')
            .borderRadius(16)
            .margin({ left: 4, right: 4 })
            .onClick(() => {
              this.currentTool = item.type;
              this.engine.updateSettings({ toolType: item.type });
            })
        }
        )
      }

      Blank()

      // 线宽调节
      Row() {
        Text('粗细')
          .fontSize(12)
          .fontColor('#666666')
        Slider({
          value: this.currentWidth,
          min: 1,
          max: 20,
          step: 1,
          style: SliderStyle.InSet
        })
          .width(100)
          .onChange((value: number) => {
            this.currentWidth = value;
            this.engine.updateSettings({ baseWidth: value });
          })
      }

      // 导出按钮
      Button('导出')
        .fontSize(14)
        .fontColor('#ffffff')
        .backgroundColor('#27ae60')
        .borderRadius(16)
        .margin({ left: 8 })
        .onClick(() => this.exportCanvas())
    }
    .width('100%')
    .height(56)
    .padding({ left: 12, right: 12 })
    .backgroundColor('#ffffff')
    .shadow({ radius: 2, color: '#10000000', offsetY: 1 })
  }

  // 底部颜色选择器
  @Builder
  ColorPicker() {
    Row() {
      ForEach(this.colorPalette, (color: string) => {
        Stack() {
          Circle()
            .width(32)
            .height(32)
            .fill(color)
          if (this.currentColor === color) {
            Circle()
              .width(36)
              .height(36)
              .fillOpacity(0)
              .stroke('#3498db')
              .strokeWidth(2.5)
          }
        }
        .margin({ left: 8, right: 8 })
        .onClick(() => {
          this.currentColor = color;
          this.engine.updateSettings({ color: color });
        })
      }
        )
    }
    .width('100%')
    .height(52)
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#ffffff')
    .shadow({ radius: 2, color: '#10000000', offsetY: -1 })
  }

  // 初始化画布
  private initCanvas(): void {
    this.context.lineWidth = 2;
    this.context.strokeStyle = '#1a1a1a';
    this.context.lineCap = 'round';
    this.context.lineJoin = 'round';
  }

  // 绘制开始
  private onDrawStart(event: GestureEvent): void {
    const x = event.fingerList[0].localX;
    const y = event.fingerList[0].localY;
    this.engine.updateSettings({
      color: this.currentColor,
      baseWidth: this.currentWidth,
      toolType: this.currentTool
    });
    this.engine.startPath(x, y);
  }

  // 绘制移动
  private onDrawMove(event: GestureEvent): void {
    const x = event.fingerList[0].localX;
    const y = event.fingerList[0].localY;
    this.engine.addPoint(x, y);

    // 实时绘制当前笔触
    const currentPath = this.engine.getCurrentPath();
    if (currentPath && currentPath.points.length >= 2) {
      this.drawStrokeSegment(currentPath);
    }
  }

  // 绘制结束
  private onDrawEnd(): void {
    const path = this.engine.endPath();
    if (path) {
      this.undoRedo.pushStroke(path);
      this.canUndo = this.undoRedo.canUndo();
      this.canRedo = this.undoRedo.canRedo();
      this.strokeCount++;
    }
  }

  // 绘制笔触片段(增量绘制,性能更好)
  private drawStrokeSegment(path: StrokePath): void {
    const points = path.points;
    const len = points.length;
    if (len < 2) return;

    const prev = points[len - 2];
    const curr = points[len - 1];

    this.context.beginPath();

    // 根据工具类型设置绘制样式
    if (path.toolType === ToolType.ERASER) {
      this.context.globalCompositeOperation = 'destination-out';
      this.context.lineWidth = path.baseWidth * 5;
    } else if (path.toolType === ToolType.HIGHLIGHTER) {
      this.context.globalCompositeOperation = 'source-over';
      this.context.globalAlpha = 0.3;
      this.context.lineWidth = path.baseWidth * 3;
    } else {
      this.context.globalCompositeOperation = 'source-over';
      this.context.globalAlpha = 1.0;
      // 速度感应线宽
      const width = StrokeCalculator.calcWidthBySpeed(prev, curr, path.baseWidth);
      this.context.lineWidth = width;
    }

    this.context.strokeStyle = path.color;
    this.context.lineCap = 'round';
    this.context.lineJoin = 'round';

    // 使用二次贝塞尔曲线平滑
    if (len >= 3) {
      const pprev = points[len - 3];
      const cpx = prev.x;
      const cpy = prev.y;
      this.context.moveTo((pprev.x + prev.x) / 2, (pprev.y + prev.y) / 2);
      this.context.quadraticCurveTo(cpx, cpy, (prev.x + curr.x) / 2, (prev.y + curr.y) / 2);
    } else {
      this.context.moveTo(prev.x, prev.y);
      this.context.lineTo(curr.x, curr.y);
    }

    this.context.stroke();
    this.context.closePath();
  }

  // 重绘全部路径
  private redrawAll(): void {
    this.context.clearRect(0, 0, 9999, 9999);
    const paths = this.engine.getPaths();
    for (const path of paths) {
      this.drawFullPath(path);
    }
  }

  // 绘制完整路径
  private drawFullPath(path: StrokePath): void {
    const points = StrokeCalculator.smoothPoints(path.points);
    if (points.length < 2) return;

    this.context.beginPath();

    // 设置绘制样式
    if (path.toolType === ToolType.ERASER) {
      this.context.globalCompositeOperation = 'destination-out';
      this.context.lineWidth = path.baseWidth * 5;
    } else if (path.toolType === ToolType.HIGHLIGHTER) {
      this.context.globalCompositeOperation = 'source-over';
      this.context.globalAlpha = 0.3;
      this.context.lineWidth = path.baseWidth * 3;
    } else {
      this.context.globalCompositeOperation = 'source-over';
      this.context.globalAlpha = 1.0;
      this.context.lineWidth = path.baseWidth;
    }

    this.context.strokeStyle = path.color;
    this.context.lineCap = 'round';
    this.context.lineJoin = 'round';

    this.context.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length - 1; i++) {
      const cpx = points[i].x;
      const cpy = points[i].y;
      const endX = (points[i].x + points[i + 1].x) / 2;
      const endY = (points[i].y + points[i + 1].y) / 2;
      this.context.quadraticCurveTo(cpx, cpy, endX, endY);
    }
    // 连接最后一个点
    const last = points[points.length - 1];
    this.context.lineTo(last.x, last.y);

    this.context.stroke();
    this.context.closePath();
  }

  // 撤销操作
  private handleUndo(): void {
    const path = this.undoRedo.undo();
    if (path) {
      this.engine.removeLastPath();
      this.redrawAll();
      this.canUndo = this.undoRedo.canUndo();
      this.canRedo = this.undoRedo.canRedo();
      this.strokeCount--;
    }
  }

  // 重做操作
  private handleRedo(): void {
    const path = this.undoRedo.redo();
    if (path) {
      this.engine.addPath(path);
      this.redrawAll();
      this.canUndo = this.undoRedo.canUndo();
      this.canRedo = this.undoRedo.canRedo();
      this.strokeCount++;
    }
  }

  // 导出画布为图片
  private exportCanvas(): void {
    try {
      // 获取画布快照
      const pixelMap = this.context.getPixelMap();
      if (pixelMap) {
        // 保存到相册
        const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(getContext(this));
        const uri = phAccessHelper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'png');
        if (uri) {
          const file = fs.openSync(uri, fs.OpenMode.WRITE_ONLY);
          const packer = image.createImagePacker(file.fd);
          packer.packSync(pixelMap, { format: 'image/png', quality: 100 });
          packer.release();
          fs.closeSync(file);
          // 提示导出成功
          promptAction.showToast({ message: '白板已保存到相册' });
        }
      }
    } catch (err) {
      promptAction.showToast({ message: '导出失败,请检查权限' });
    }
  }
}

// 扩展DrawingEngine方法
class DrawingEngine {
  // ... 前面定义的属性和方法

  private paths: StrokePath[] = [];
  private currentPath: StrokePath | null = null;
  private settings: DrawingSettings = {
    color: '#000000',
    baseWidth: 3,
    toolType: ToolType.PEN
  };

  // 获取当前路径
  getCurrentPath(): StrokePath | null {
    return this.currentPath;
  }

  // 移除最后一条路径
  removeLastPath(): void {
    if (this.paths.length > 0) {
      this.paths.pop();
    }
  }

  // 添加路径(用于重做)
  addPath(path: StrokePath): void {
    this.paths.push(path);
  }

  startPath(x: number, y: number): void {
    const point: DrawPoint = { x, y, timestamp: Date.now(), pressure: 0.5 };
    this.currentPath = {
      points: [point],
      color: this.settings.color,
      baseWidth: this.settings.baseWidth,
      toolType: this.settings.toolType
    };
  }

  addPoint(x: number, y: number): void {
    if (!this.currentPath) return;
    this.currentPath.points.push({ x, y, timestamp: Date.now(), pressure: 0.5 });
  }

  endPath(): StrokePath | null {
    if (!this.currentPath) return null;
    const path = this.currentPath;
    this.paths.push(path);
    this.currentPath = null;
    return path;
  }

  getPaths(): StrokePath[] {
    return this.paths;
  }

  updateSettings(settings: Partial<DrawingSettings>): void {
    this.settings = { ...this.settings, ...settings };
  }
}

四、踩坑与注意事项

坑点1:PanGesture与Canvas的坐标偏移

Canvas组件在使用PanGesture时,fingerList[0].localX/localY 返回的是相对于Canvas组件的局部坐标。但如果Canvas外层有padding或其他布局偏移,坐标可能不准确。务必确保Canvas的宽高与实际绘制区域一致,或者在计算坐标时加上偏移量。

坑点2:globalCompositeOperation重置问题

使用橡皮擦时设置了 destination-out 混合模式,绘制完毕后必须重置为 source-over,否则后续所有绘制都会变成"擦除"效果。同样,荧光笔的 globalAlpha 也需要在绘制完毕后恢复为 1.0。

坑点3:增量绘制与全量重绘的闪烁问题

实时绘制时使用增量绘制(只画新增的线段),性能好但可能导致线条连接处有细微断裂。撤销重做时需要全量重绘,此时如果先 clearRect 再逐条绘制,会出现短暂闪烁。解决方案:使用双缓冲Canvas,先在离屏Canvas上绘制完成,再一次性拷贝到显示Canvas。

坑点4:高频触摸事件导致性能下降

手指在屏幕上滑动时,触摸事件频率非常高(可达每秒120次以上),每次都触发Canvas绘制会导致性能问题。建议做节流处理:当两个采样点距离小于2像素时跳过,既减少绘制次数又不影响线条平滑度。

坑点5:导出图片时画布内容为空

getPixelMap() 只能获取Canvas上已渲染的内容。如果在 onReady 之前就尝试绘制,或者绘制后没有调用 stroke()/fill(),导出的图片可能是一片空白。确保所有绘制操作都在 onReady 回调之后执行,并且每次路径都正确关闭。

坑点6:撤销重做与橡皮擦的冲突

橡皮擦使用 destination-out 混合模式,本质上是"擦除已有像素"。但撤销时我们只是移除了最后一条路径并重绘,橡皮擦的擦除效果无法通过简单重绘来恢复。解决方案:将橡皮擦也当作普通路径存储,重绘时按顺序执行所有操作(包括擦除),这样撤销时重新执行整个路径列表即可正确恢复。


五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
Canvas.onReady 组件挂载后触发 组件挂载后触发,时机更早 无需修改,但注意6.0中更早触发可能影响初始化顺序
RenderingContext.getPixelMap 返回PixelMap 返回Promise<PixelMap> 需改用await/then异步调用
PanGesture回调参数 GestureEvent GestureEvent(新增force字段) 可利用force字段实现压感笔触
image.createImagePacker 同步创建 返回ImagePacker(新增packAsync) 推荐使用packAsync避免阻塞UI线程
photoAccessHelper.createAsset 同步返回uri 异步返回Promise<string> 需改用await/then异步调用

行为变更

  • Canvas硬件加速默认开启:HarmonyOS 6.0中Canvas默认启用硬件加速,绘制性能提升约30%,但 globalCompositeOperation 的某些模式(如 destination-out)行为可能略有差异,建议充分测试橡皮擦功能
  • 触摸事件采样率提升:6.0的触摸采样率从120Hz提升至240Hz,高频事件更多,需要加强节流处理
  • getPixelMap异步化:导出图片必须使用异步方式,同步调用会抛出异常

适配代码

// HarmonyOS 6适配:异步导出画布
private async exportCanvasV6(): Promise<void> {
  try {
    // 6.0中getPixelMap返回Promise
    const pixelMap = await this.context.getPixelMap();
    if (!pixelMap) {
      promptAction.showToast({ message: '获取画布数据失败' });
      return;
    }

    const context = getContext(this);
    const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
    // 6.0中createAsset异步化
    const assetUri = await phAccessHelper.createAsset(
      photoAccessHelper.PhotoType.IMAGE, 'png'
    );

    const file = fs.openSync(assetUri, fs.OpenMode.WRITE_ONLY);
    const packer = image.createImagePacker(file.fd);
    // 6.0推荐使用packAsync
    await packer.packAsync(pixelMap, { format: 'image/png', quality: 100 });
    packer.release();
    fs.closeSync(file);

    promptAction.showToast({ message: '白板已保存到相册' });
  } catch (err) {
    promptAction.showToast({ message: '导出失败:' + (err as Error).message });
  }
}

// HarmonyOS 6适配:利用force字段实现压感笔触
private onDrawStartV6(event: GestureEvent): void {
  const finger = event.fingerList[0];
  const x = finger.localX;
  const y = finger.localY;
  // 6.0新增force字段,支持压感
  const pressure = finger.force !== undefined ? finger.force : 0.5;
  this.engine.startPath(x, y);
  // 更新当前路径第一个点的压力值
  const currentPath = this.engine.getCurrentPath();
  if (currentPath) {
    currentPath.points[0].pressure = pressure;
  }
}

六、总结

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

手绘白板应用看似简单,实则暗藏玄机。从绘制引擎的架构设计到笔触效果的算法实现,从撤销重做的状态管理到导出分享的权限处理,每一个环节都需要精心打磨。本文我们从零搭建了完整的白板应用,核心收获有三点:

第一,绘制引擎要分层设计。 交互层、引擎层、数据层各司其职,才能让代码清晰可维护。别把所有逻辑都塞进手势回调里,那样后期扩展会非常痛苦。

第二,笔触效果是白板应用的灵魂。 速度感应线宽 + 贝塞尔曲线平滑,这两招组合拳就能让数字笔触拥有"纸笔感"。如果设备支持压感(HarmonyOS 6.0的force字段),效果还能再上一个台阶。

第三,撤销重做要用命令模式。 不要试图用"保存画布快照"的方式实现撤销——那会吃光内存。命令模式只存储操作数据,内存占用小、恢复速度快,才是正道。

白板应用还有很多可以扩展的方向:形状识别(画圆自动变成正圆)、文字输入、多人协作、贴图功能……这些都是在本文基础架构上的自然延伸。掌握了核心原理,剩下的就是创意和时间的问题了。

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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