HarmonyOS APP开发:绘图工具与图片编辑器实战

举报
Jack20 发表于 2026/06/22 23:02:31 2026/06/22
【摘要】 HarmonyOS APP开发:绘图工具与图片编辑器实战📌 核心要点:从画笔渲染到图层系统,手把手打造一个功能完整的图片编辑器 一、背景与动机你有没有想过,手机上那些功能强大的图片编辑器——比如美图秀秀、Snapseed——它们背后的技术原理是什么?当你在屏幕上随手画下一笔,系统是如何把你的手指轨迹变成流畅的线条的?当你给照片加滤镜时,像素数据又是怎样被逐个变换的?这些问题,在Harmo...

HarmonyOS APP开发:绘图工具与图片编辑器实战

📌 核心要点:从画笔渲染到图层系统,手把手打造一个功能完整的图片编辑器


一、背景与动机

你有没有想过,手机上那些功能强大的图片编辑器——比如美图秀秀、Snapseed——它们背后的技术原理是什么?当你在屏幕上随手画下一笔,系统是如何把你的手指轨迹变成流畅的线条的?当你给照片加滤镜时,像素数据又是怎样被逐个变换的?

这些问题,在HarmonyOS上同样存在,而且因为ArkTS的声明式UI范式,实现方式还有自己的特色。

想象一下这个场景:你正在开发一个社交APP,用户需要给照片打马赛克、加标注、画箭头;或者你在做一个教育类应用,学生需要在PDF上做笔记、圈画重点。这些需求都指向同一个技术方向——绘图工具与图片编辑器

但说实话,绘图工具的开发并不简单。它涉及触摸事件处理、Canvas渲染、图层管理、滤镜算法等多个技术领域,每一个都是"坑点密集区"。很多开发者一上来就写代码,结果画笔卡顿、图层混乱、内存爆炸,最后只能推倒重来。

所以这篇文章,咱们不搞花架子,从架构设计到代码实现,把绘图工具和图片编辑器的核心知识点一次性讲透。


二、核心原理

2.1 图片编辑器整体架构

一个完整的图片编辑器,核心架构可以分成五层:

graph TD
    A[交互层<br>触摸事件/手势识别]:::primary --> B[工具层<br>画笔/橡皮/选区/滤镜]:::info
    B --> C[图层系统<br>图层栈/混合模式/合成]:::warning
    C --> D[渲染引擎<br>Canvas绘制/离屏缓冲]:::info
    D --> E[数据持久层<br>图片编解码/文件存储]:::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

交互层负责捕获用户的触摸事件,把手势转化为绘图指令;工具层是各种绘图工具的具体实现;图层系统管理多个图层的叠加和合成;渲染引擎负责最终的Canvas绘制;数据持久层处理图片的加载、保存和编解码。

这五层各司其职,互不干扰。你换一个画笔工具,只需要改工具层的代码,其他层完全不受影响。这就是架构设计的魅力——解耦让一切变得可控

2.2 画笔渲染原理

画笔工具的核心问题是:如何把离散的触摸点连成平滑的曲线?

最简单的做法是直接连线——相邻两个触摸点之间画一条直线。但你会发现,手指移动速度快的时候,采样点之间的间距很大,画出来的线条全是折角,像锯齿一样。

解决方案是贝塞尔曲线插值。我们用二次贝塞尔曲线把相邻的采样点平滑连接起来,控制点取相邻两点的中点,这样就能得到丝滑的笔迹。

graph LR
    A[触摸点P0]:::primary --> B[中点M1]:::info
    B --> C[触摸点P1]:::primary
    C --> D[中点M2]:::info
    D --> E[触摸点P2]:::primary
    
    F[贝塞尔曲线段1<br>M0M1, 控制点P0]:::warning
    G[贝塞尔曲线段2<br>M1M2, 控制点P1]:::warning

    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff

2.3 图层合成原理

图层系统的核心是合成算法。多个图层叠加时,每个像素的最终颜色取决于所有图层在该位置的像素值和混合模式。

最常见的混合模式是Alpha合成(Porter-Duff Over操作):

结果颜色 = 源颜色 × 源Alpha + 目标颜色 × (1 - 源Alpha)

当你在图层A上叠加图层B时,B的像素会根据其透明度和A的像素进行混合,这就是我们常说的"半透明叠加"效果。


三、代码实战

3.1 基础用法:铅笔工具实现

先从最简单的铅笔工具开始,感受一下触摸绘图的基本流程:

// 铅笔工具组件
@Component
struct PencilTool {
  // 画笔路径数据
  private paths: Array<Array<Point>> = []
  private currentPath: Array<Point> = []
  
  // 画笔配置
  @State strokeColor: string = '#000000'
  @State strokeWidth: number = 3

  build() {
    Stack() {
      // 画布组件
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .onReady(() => {
          this.onCanvasReady()
        })
        .onTouch((event: TouchEvent) => {
          this.handleTouchEvent(event)
        })
    }
  }

  // 画布初始化
  private onCanvasReady() {
    this.context.lineWidth = this.strokeWidth
    this.context.strokeStyle = this.strokeColor
    this.context.lineCap = 'round'
    this.context.lineJoin = 'round'
  }

  // 处理触摸事件
  private handleTouchEvent(event: TouchEvent) {
    const touch = event.touches[0]
    const point: Point = { x: touch.x, y: touch.y }

    switch (event.type) {
      case TouchType.Down:
        // 手指按下,开始新路径
        this.currentPath = [point]
        this.context.beginPath()
        this.context.moveTo(point.x, point.y)
        break
        
      case TouchType.Move:
        // 手指移动,追加路径点并绘制
        this.currentPath.push(point)
        this.context.lineTo(point.x, point.y)
        this.context.stroke()
        break
        
      case TouchType.Up:
        // 手指抬起,保存路径
        this.paths.push([...this.currentPath])
        this.currentPath = []
        break
    }
  }
}

// 坐标点接口
interface Point {
  x: number
  y: number
}

这段代码实现了最基本的铅笔绘图:手指按下开始新路径,移动时逐点绘制,抬起时保存路径。但你会发现,快速滑动时线条会有明显的锯齿——这就是前面说的采样点间距问题。

3.2 进阶用法:毛笔效果与贝塞尔平滑

毛笔和铅笔最大的区别在于:毛笔的笔触宽度会随书写速度变化——慢写时粗,快写时细。这个效果怎么实现呢?

核心思路是根据相邻采样点的距离(即速度的倒数)来动态调整线宽

// 毛笔工具 - 带贝塞尔平滑和动态笔宽
@Component
struct BrushTool {
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
  private paths: Array<BrushStroke> = []
  private currentPoints: Array<Point> = []
  
  @State strokeColor: string = '#1a1a1a'
  @State baseWidth: number = 8       // 基础笔宽
  @State minWidth: number = 2        // 最小笔宽
  @State maxWidth: number = 16       // 最大笔宽
  @State velocityFilter: number = 0.7 // 速度滤波系数

  build() {
    Stack() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .onReady(() => {
          this.initCanvas()
        })
        .onTouch((event: TouchEvent) => {
          this.handleBrushTouch(event)
        })
    }
  }

  private initCanvas() {
    this.context.lineCap = 'round'
    this.context.lineJoin = 'round'
  }

  // 处理毛笔触摸事件
  private handleBrushTouch(event: TouchEvent) {
    const touch = event.touches[0]
    const point: Point = { x: touch.x, y: touch.y }

    if (event.type === TouchType.Down) {
      this.currentPoints = [point]
      // 画一个起始圆点
      this.drawDot(point, this.baseWidth)
    } else if (event.type === TouchType.Move) {
      this.currentPoints.push(point)
      if (this.currentPoints.length >= 3) {
        // 使用贝塞尔曲线平滑绘制
        this.drawSmoothLine()
      }
    } else if (event.type === TouchType.Up) {
      // 保存笔画数据
      const stroke: BrushStroke = {
        points: [...this.currentPoints],
        color: this.strokeColor,
        baseWidth: this.baseWidth
      }
      this.paths.push(stroke)
      this.currentPoints = []
    }
  }

  // 绘制平滑曲线
  private drawSmoothLine() {
    const len = this.currentPoints.length
    if (len < 3) return

    const p0 = this.currentPoints[len - 3]
    const p1 = this.currentPoints[len - 2]
    const p2 = this.currentPoints[len - 1]

    // 计算中点作为贝塞尔端点
    const mid1: Point = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }
    const mid2: Point = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }

    // 根据速度计算动态笔宽
    const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
    const velocity = Math.min(distance, 50) // 限制最大速度
    const targetWidth = this.maxWidth - (velocity / 50) * (this.maxWidth - this.minWidth)
    
    // 平滑过渡笔宽
    const smoothWidth = this.baseWidth * (1 - this.velocityFilter) + targetWidth * this.velocityFilter
    this.baseWidth = smoothWidth

    // 绘制贝塞尔曲线段
    this.context.beginPath()
    this.context.lineWidth = smoothWidth
    this.context.strokeStyle = this.strokeColor
    this.context.moveTo(mid1.x, mid1.y)
    this.context.quadraticCurveTo(p1.x, p1.y, mid2.x, mid2.y)
    this.context.stroke()
  }

  // 绘制圆点(起始点)
  private drawDot(point: Point, width: number) {
    this.context.beginPath()
    this.context.fillStyle = this.strokeColor
    this.context.arc(point.x, point.y, width / 2, 0, Math.PI * 2)
    this.context.fill()
  }
}

// 毛笔笔画数据
interface BrushStroke {
  points: Array<Point>
  color: string
  baseWidth: number
}

这段代码的精髓在于两个地方:一是用quadraticCurveTo绘制二次贝塞尔曲线实现平滑笔迹;二是根据手指移动速度动态调整线宽,模拟毛笔的书写效果。

3.3 完整示例:图片编辑器

下面是一个包含画笔、橡皮擦、图层、滤镜等完整功能的图片编辑器:

// 图片编辑器主页面
@Entry
@Component
struct ImageEditorPage {
  // 画布上下文 - 主画布
  private mainContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
  // 画布上下文 - 临时画布(用于实时预览)
  private tempContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(new Settings())
  
  // 编辑器状态
  @State currentTool: EditorTool = EditorTool.PENCIL
  @State strokeColor: string = '#FF0000'
  @State strokeWidth: number = 5
  @State eraserSize: number = 20
  @State layers: Array<LayerData> = []
  @State activeLayerIndex: number = 0
  @State sourceImage: PixelMap | null = null
  @State filterType: FilterType = FilterType.NONE
  @State brightness: number = 0
  @State contrast: number = 1.0
  @State saturation: number = 1.0
  @State canvasWidth: number = 1080
  @State canvasHeight: number = 1920

  // 撤销/重做栈
  private undoStack: Array<ImageData> = []
  private redoStack: Array<ImageData> = []
  private maxUndoSteps: number = 20

  aboutToAppear() {
    this.initEditor()
  }

  build() {
    Column() {
      // 顶部工具栏
      this.Toolbar()
      
      // 画布区域
      Stack() {
        // 主画布 - 显示合成结果
        Canvas(this.mainContext)
          .width('100%')
          .height('100%')
          .onReady(() => {
            this.onMainCanvasReady()
          })
        
        // 临时画布 - 绘制中的实时预览
        Canvas(this.tempContext)
          .width('100%')
          .height('100%')
          .onReady(() => {
            this.onTempCanvasReady()
          })
          .onTouch((event: TouchEvent) => {
            this.handleEditorTouch(event)
          })
      }
      .layoutWeight(1)
      .backgroundColor('#F5F5F5')
      
      // 底部属性面板
      this.BottomPanel()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  // 顶部工具栏
  @Builder Toolbar() {
    Row() {
      // 撤销
      Button('撤销')
        .onClick(() => this.undo())
        .fontSize(14)
        .fontColor('#333333')
        .backgroundColor('#EEEEEE')
      
      // 重做
      Button('重做')
        .onClick(() => this.redo())
        .fontSize(14)
        .fontColor('#333333')
        .backgroundColor('#EEEEEE')
      
      Blank()
      
      // 保存
      Button('保存')
        .onClick(() => this.saveImage())
        .fontSize(14)
        .fontColor('#FFFFFF')
        .backgroundColor('#4CAF50')
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .justifyContent(FlexAlign.SpaceBetween)
  }

  // 底部属性面板
  @Builder BottomPanel() {
    Column() {
      // 工具选择
      Row() {
        ForEach([
          { tool: EditorTool.PENCIL, icon: '✏️', label: '铅笔' },
          { tool: EditorTool.BRUSH, icon: '🖌️', label: '毛笔' },
          { tool: EditorTool.MARKER, icon: '🖊️', label: '马克笔' },
          { tool: EditorTool.ERASER, icon: '🧹', label: '橡皮' },
          { tool: EditorTool.SELECTION, icon: '⬜', label: '选区' },
          { tool: EditorTool.FILTER, icon: '🎨', label: '滤镜' }
        ], (item: ToolItem) => {
          Column() {
            Text(item.icon).fontSize(24)
            Text(item.label).fontSize(10).fontColor('#666666')
          }
          .width(56)
          .height(56)
          .borderRadius(8)
          .justifyContent(FlexAlign.Center)
          .backgroundColor(this.currentTool === item.tool ? '#E3F2FD' : '#FFFFFF')
          .border(this.currentTool === item.tool ? 2 : 0, '#2196F3')
          .onClick(() => {
            this.currentTool = item.tool
          })
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .padding({ top: 8, bottom: 8 })
      
      // 画笔属性
      if (this.currentTool === EditorTool.PENCIL || 
          this.currentTool === EditorTool.BRUSH ||
          this.currentTool === EditorTool.MARKER) {
        Row() {
          Text('粗细:').fontSize(12)
          Slider({ value: this.strokeWidth, min: 1, max: 30 })
            .width(150)
            .onChange((value: number) => {
              this.strokeWidth = value
            })
          Text('颜色:').fontSize(12).margin({ left: 16 })
          // 颜色选择器
          Row() {
            ForEach(['#FF0000', '#00FF00', '#0000FF', '#000000', '#FFFFFF'], (color: string) => {
              Circle()
                .width(24)
                .height(24)
                .fill(color)
                .border(1, '#CCCCCC')
                .onClick(() => { this.strokeColor = color })
            })
          }
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 4, bottom: 4 })
      }
      
      // 滤镜属性
      if (this.currentTool === EditorTool.FILTER) {
        Row() {
          ForEach([
            { type: FilterType.NONE, label: '原图' },
            { type: FilterType.GRAYSCALE, label: '灰度' },
            { type: FilterType.SEPIA, label: '复古' },
            { type: FilterType.INVERT, label: '反色' },
            { type: FilterType.BLUR, label: '模糊' }
          ], (item: FilterItem) => {
            Text(item.label)
              .fontSize(12)
              .padding(8)
              .borderRadius(4)
              .backgroundColor(this.filterType === item.type ? '#2196F3' : '#EEEEEE')
              .fontColor(this.filterType === item.type ? '#FFFFFF' : '#333333')
              .onClick(() => {
                this.filterType = item.type
                this.applyFilter()
              })
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceAround)
        .padding({ top: 4, bottom: 4 })
      }
    }
    .width('100%')
    .backgroundColor('#FAFAFA')
    .border({ width: { top: 1 }, color: '#E0E0E0' })
  }

  // 初始化编辑器
  private async initEditor() {
    // 创建默认图层
    const bgLayer: LayerData = {
      id: 0,
      name: '背景',
      visible: true,
      opacity: 1.0,
      blendMode: BlendMode.NORMAL,
      pixels: null
    }
    const drawLayer: LayerData = {
      id: 1,
      name: '绘画层',
      visible: true,
      opacity: 1.0,
      blendMode: BlendMode.NORMAL,
      pixels: null
    }
    this.layers = [bgLayer, drawLayer]
    this.activeLayerIndex = 1
  }

  // 主画布就绪
  private onMainCanvasReady() {
    this.mainContext.fillStyle = '#FFFFFF'
    this.mainContext.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
    this.compositeAndRender()
  }

  // 临时画布就绪
  private onTempCanvasReady() {
    this.tempContext.lineCap = 'round'
    this.tempContext.lineJoin = 'round'
  }

  // 处理编辑器触摸事件
  private handleEditorTouch(event: TouchEvent) {
    const touch = event.touches[0]
    const point: Point = { x: touch.x, y: touch.y }

    switch (this.currentTool) {
      case EditorTool.PENCIL:
        this.handlePencilDraw(event, point)
        break
      case EditorTool.BRUSH:
        this.handleBrushDraw(event, point)
        break
      case EditorTool.MARKER:
        this.handleMarkerDraw(event, point)
        break
      case EditorTool.ERASER:
        this.handleEraser(event, point)
        break
      case EditorTool.SELECTION:
        this.handleSelection(event, point)
        break
      default:
        break
    }
  }

  // 铅笔绘制
  private handlePencilDraw(event: TouchEvent, point: Point) {
    if (event.type === TouchType.Down) {
      this.saveUndoState()
      this.tempContext.beginPath()
      this.tempContext.lineWidth = this.strokeWidth
      this.tempContext.strokeStyle = this.strokeColor
      this.tempContext.moveTo(point.x, point.y)
    } else if (event.type === TouchType.Move) {
      this.tempContext.lineTo(point.x, point.y)
      this.tempContext.stroke()
    } else if (event.type === TouchType.Up) {
      // 将临时画布内容合并到当前图层
      this.mergeTempToLayer()
      this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
    }
  }

  // 马克笔绘制(半透明效果)
  private handleMarkerDraw(event: TouchEvent, point: Point) {
    if (event.type === TouchType.Down) {
      this.saveUndoState()
      this.tempContext.beginPath()
      this.tempContext.lineWidth = this.strokeWidth * 3 // 马克笔更粗
      this.tempContext.strokeStyle = this.strokeColor
      this.tempContext.globalAlpha = 0.4 // 半透明效果
      this.tempContext.moveTo(point.x, point.y)
    } else if (event.type === TouchType.Move) {
      this.tempContext.lineTo(point.x, point.y)
      this.tempContext.stroke()
    } else if (event.type === TouchType.Up) {
      this.tempContext.globalAlpha = 1.0
      this.mergeTempToLayer()
      this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
    }
  }

  // 毛笔绘制(带动态笔宽)
  private brushPoints: Array<Point> = []
  private handleBrushDraw(event: TouchEvent, point: Point) {
    if (event.type === TouchType.Down) {
      this.saveUndoState()
      this.brushPoints = [point]
      this.tempContext.beginPath()
      this.tempContext.strokeStyle = this.strokeColor
      this.tempContext.moveTo(point.x, point.y)
    } else if (event.type === TouchType.Move) {
      this.brushPoints.push(point)
      if (this.brushPoints.length >= 3) {
        const len = this.brushPoints.length
        const p0 = this.brushPoints[len - 3]
        const p1 = this.brushPoints[len - 2]
        const p2 = this.brushPoints[len - 1]
        const mid1 = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }
        const mid2 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
        // 动态笔宽
        const dist = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
        const dynamicWidth = Math.max(2, Math.min(20, 30 / (dist + 1)))
        this.tempContext.lineWidth = dynamicWidth
        this.tempContext.beginPath()
        this.tempContext.moveTo(mid1.x, mid1.y)
        this.tempContext.quadraticCurveTo(p1.x, p1.y, mid2.x, mid2.y)
        this.tempContext.stroke()
      }
    } else if (event.type === TouchType.Up) {
      this.mergeTempToLayer()
      this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
      this.brushPoints = []
    }
  }

  // 橡皮擦
  private handleEraser(event: TouchEvent, point: Point) {
    if (event.type === TouchType.Down) {
      this.saveUndoState()
    }
    if (event.type === TouchType.Move || event.type === TouchType.Down) {
      this.tempContext.save()
      this.tempContext.globalCompositeOperation = 'destination-out'
      this.tempContext.beginPath()
      this.tempContext.arc(point.x, point.y, this.eraserSize / 2, 0, Math.PI * 2)
      this.tempContext.fill()
      this.tempContext.restore()
    }
    if (event.type === TouchType.Up) {
      this.mergeTempToLayer()
      this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
    }
  }

  // 选区工具(矩形选区)
  private selectionStart: Point = { x: 0, y: 0 }
  private selectionEnd: Point = { x: 0, y: 0 }
  private handleSelection(event: TouchEvent, point: Point) {
    if (event.type === TouchType.Down) {
      this.selectionStart = point
    } else if (event.type === TouchType.Move) {
      this.selectionEnd = point
      // 绘制选区框
      this.tempContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
      this.tempContext.strokeStyle = '#2196F3'
      this.tempContext.lineWidth = 2
      this.tempContext.setLineDash([6, 4]) // 虚线选区
      const x = Math.min(this.selectionStart.x, this.selectionEnd.x)
      const y = Math.min(this.selectionStart.y, this.selectionEnd.y)
      const w = Math.abs(this.selectionEnd.x - this.selectionStart.x)
      const h = Math.abs(this.selectionEnd.y - this.selectionStart.y)
      this.tempContext.strokeRect(x, y, w, h)
      this.tempContext.setLineDash([])
    }
  }

  // 将临时画布内容合并到当前图层
  private mergeTempToLayer() {
    const activeLayer = this.layers[this.activeLayerIndex]
    if (activeLayer) {
      // 获取临时画布的像素数据
      const tempImageData = this.tempContext.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
      // 合并到图层(简化实现,实际需要处理混合模式)
      if (!activeLayer.pixels) {
        activeLayer.pixels = tempImageData
      } else {
        // Alpha合成
        this.blendPixels(activeLayer.pixels, tempImageData)
      }
    }
    this.compositeAndRender()
  }

  // 像素混合(Alpha合成)
  private blendPixels(dest: ImageData, src: ImageData) {
    const d = dest.data
    const s = src.data
    for (let i = 0; i < d.length; i += 4) {
      const srcAlpha = s[i + 3] / 255
      const destAlpha = d[i + 3] / 255
      const outAlpha = srcAlpha + destAlpha * (1 - srcAlpha)
      if (outAlpha > 0) {
        d[i] = (s[i] * srcAlpha + d[i] * destAlpha * (1 - srcAlpha)) / outAlpha
        d[i + 1] = (s[i + 1] * srcAlpha + d[i + 1] * destAlpha * (1 - srcAlpha)) / outAlpha
        d[i + 2] = (s[i + 2] * srcAlpha + d[i + 2] * destAlpha * (1 - srcAlpha)) / outAlpha
        d[i + 3] = outAlpha * 255
      }
    }
  }

  // 合成所有图层并渲染到主画布
  private compositeAndRender() {
    this.mainContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
    // 绘制白色背景
    this.mainContext.fillStyle = '#FFFFFF'
    this.mainContext.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
    
    // 从底层到顶层依次合成
    for (const layer of this.layers) {
      if (!layer.visible || !layer.pixels) continue
      this.mainContext.globalAlpha = layer.opacity
      this.mainContext.putImageData(layer.pixels, 0, 0)
    }
    this.mainContext.globalAlpha = 1.0
  }

  // 应用滤镜
  private applyFilter() {
    const activeLayer = this.layers[this.activeLayerIndex]
    if (!activeLayer || !activeLayer.pixels) return
    
    const data = activeLayer.pixels.data
    switch (this.filterType) {
      case FilterType.GRAYSCALE:
        // 灰度滤镜
        for (let i = 0; i < data.length; i += 4) {
          const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114
          data[i] = data[i + 1] = data[i + 2] = gray
        }
        break
        
      case FilterType.SEPIA:
        // 复古滤镜
        for (let i = 0; i < data.length; i += 4) {
          const r = data[i], g = data[i + 1], b = data[i + 2]
          data[i] = Math.min(255, r * 0.393 + g * 0.769 + b * 0.189)
          data[i + 1] = Math.min(255, r * 0.349 + g * 0.686 + b * 0.168)
          data[i + 2] = Math.min(255, r * 0.272 + g * 0.534 + b * 0.131)
        }
        break
        
      case FilterType.INVERT:
        // 反色滤镜
        for (let i = 0; i < data.length; i += 4) {
          data[i] = 255 - data[i]
          data[i + 1] = 255 - data[i + 1]
          data[i + 2] = 255 - data[i + 2]
        }
        break
        
      case FilterType.BLUR:
        // 简单均值模糊(3x3卷积核)
        this.applyBoxBlur(activeLayer.pixels, 3)
        break
        
      default:
        break
    }
    this.compositeAndRender()
  }

  // 均值模糊
  private applyBoxBlur(imageData: ImageData, radius: number) {
    const w = imageData.width
    const h = imageData.height
    const src = new Uint8ClampedArray(imageData.data)
    const dst = imageData.data
    const size = radius * 2 + 1
    const area = size * size
    
    for (let y = radius; y < h - radius; y++) {
      for (let x = radius; x < w - radius; x++) {
        let r = 0, g = 0, b = 0
        for (let dy = -radius; dy <= radius; dy++) {
          for (let dx = -radius; dx <= radius; dx++) {
            const idx = ((y + dy) * w + (x + dx)) * 4
            r += src[idx]
            g += src[idx + 1]
            b += src[idx + 2]
          }
        }
        const idx = (y * w + x) * 4
        dst[idx] = r / area
        dst[idx + 1] = g / area
        dst[idx + 2] = b / area
      }
    }
  }

  // 保存撤销状态
  private saveUndoState() {
    const activeLayer = this.layers[this.activeLayerIndex]
    if (activeLayer && activeLayer.pixels) {
      // 深拷贝当前像素数据
      const snapshot = new ImageData(
        new Uint8ClampedArray(activeLayer.pixels.data),
        activeLayer.pixels.width,
        activeLayer.pixels.height
      )
      this.undoStack.push(snapshot)
      if (this.undoStack.length > this.maxUndoSteps) {
        this.undoStack.shift()
      }
      this.redoStack = []
    }
  }

  // 撤销
  private undo() {
    if (this.undoStack.length === 0) return
    const activeLayer = this.layers[this.activeLayerIndex]
    if (activeLayer && activeLayer.pixels) {
      // 保存当前状态到重做栈
      this.redoStack.push(new ImageData(
        new Uint8ClampedArray(activeLayer.pixels.data),
        activeLayer.pixels.width,
        activeLayer.pixels.height
      ))
      // 恢复上一个状态
      activeLayer.pixels = this.undoStack.pop()!
      this.compositeAndRender()
    }
  }

  // 重做
  private redo() {
    if (this.redoStack.length === 0) return
    const activeLayer = this.layers[this.activeLayerIndex]
    if (activeLayer && activeLayer.pixels) {
      this.undoStack.push(new ImageData(
        new Uint8ClampedArray(activeLayer.pixels.data),
        activeLayer.pixels.width,
        activeLayer.pixels.height
      ))
      activeLayer.pixels = this.redoStack.pop()!
      this.compositeAndRender()
    }
  }

  // 保存图片到相册
  private async saveImage() {
    try {
      // 从主画布获取像素数据
      const imageData = this.mainContext.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
      // 创建PixelMap并保存(简化示意)
      console.info('图片保存成功')
    } catch (error) {
      console.error(`保存失败: ${error}`)
    }
  }
}

// 枚举定义
enum EditorTool {
  PENCIL = 'pencil',
  BRUSH = 'brush',
  MARKER = 'marker',
  ERASER = 'eraser',
  SELECTION = 'selection',
  FILTER = 'filter'
}

enum FilterType {
  NONE = 'none',
  GRAYSCALE = 'grayscale',
  SEPIA = 'sepia',
  INVERT = 'invert',
  BLUR = 'blur'
}

enum BlendMode {
  NORMAL = 'normal',
  MULTIPLY = 'multiply',
  SCREEN = 'screen'
}

// 图层数据接口
interface LayerData {
  id: number
  name: string
  visible: boolean
  opacity: number
  blendMode: BlendMode
  pixels: ImageData | null
}

// 工具项接口
interface ToolItem {
  tool: EditorTool
  icon: string
  label: string
}

// 滤镜项接口
interface FilterItem {
  type: FilterType
  label: string
}

interface Point {
  x: number
  y: number
}

这个完整示例涵盖了图片编辑器的核心功能:多种画笔工具、橡皮擦、选区、图层合成、滤镜、撤销重做。虽然某些API在HarmonyOS中可能有细微差异,但整体架构和算法逻辑是通用的。


四、踩坑与注意事项

坑点1:Canvas触摸事件坐标偏移

在HarmonyOS中,Canvas的触摸坐标是相对于组件左上角的,但如果Canvas外层有滚动容器或者Canvas本身有缩放,坐标就会偏移。务必在处理触摸事件时做坐标转换

// 错误做法:直接使用触摸坐标
const point = { x: touch.x, y: touch.y }

// 正确做法:考虑Canvas的实际显示区域
const canvasRect = this.canvasArea
const scaleX = this.canvasWidth / canvasRect.width
const scaleY = this.canvasHeight / canvasRect.height
const point = {
  x: (touch.x - canvasRect.left) * scaleX,
  y: (touch.y - canvasRect.top) * scaleY
}

坑点2:频繁重绘导致卡顿

每次手指移动都调用stroke()会导致Canvas反复重绘,性能很差。解决方案是使用离屏Canvas做缓冲,只在手指抬起时才把结果合并到主画布:

// 在临时画布上绘制,避免频繁操作主画布
// 手指抬起时才合并
private mergeTempToLayer() {
  // 一次性将临时画布内容绘制到主画布
  this.mainContext.drawImage(this.tempCanvas, 0, 0)
}

坑点3:橡皮擦的globalCompositeOperation兼容性

橡皮擦需要用destination-out混合模式来"擦除"像素,但不同版本的HarmonyOS对这个属性的支持可能不一致。建议在初始化时做兼容性检测,如果不支持,可以用白色画笔模拟(但只适用于白色背景)。

坑点4:图层像素数据的内存管理

每个图层都保存一份完整的ImageData,对于1080×1920的画布,一个图层就需要约8MB内存。10个图层就是80MB!务必限制最大图层数量,并在不可见图层上做内存回收

// 图层不可见时释放像素数据
private releaseHiddenLayers() {
  for (const layer of this.layers) {
    if (!layer.visible && layer.pixels) {
      layer.cachedPixels = layer.pixels  // 缓存以便恢复
      layer.pixels = null                // 释放内存
    }
  }
}

坑点5:滤镜操作阻塞UI线程

像素级的滤镜操作(遍历每个像素)非常耗时,一张1080×1920的图片有200万个像素,灰度滤镜需要遍历800万个数值。必须使用TaskPool或Worker在子线程执行

import { taskpool } from '@kit.ArkTS'

// 在子线程执行滤镜
@Concurrent
function applyGrayscaleFilter(data: Uint8ClampedArray): Uint8ClampedArray {
  for (let i = 0; i < data.length; i += 4) {
    const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114
    data[i] = data[i + 1] = data[i + 2] = gray
  }
  return data
}

// 主线程调用
private async applyFilterAsync() {
  const task = new taskpool.Task(applyGrayscaleFilter, this.pixelData)
  const result = await taskpool.execute(task)
  this.pixelData = result as Uint8ClampedArray
}

坑点6:贝塞尔曲线的起点处理

使用中点法绘制贝塞尔曲线时,第一段曲线(从触摸起点到第一个中点)容易被遗漏。需要在TouchDown时额外绘制一个起始圆点,否则线条开头会出现断裂。

坑点7:马克笔的透明度叠加问题

马克笔的特点是半透明,但如果在同一个笔画内反复经过同一区域,透明度会不断叠加变深。解决方案是在临时画布上以不透明方式绘制,合并时再应用透明度,这样同一笔画内的透明度就是均匀的。


五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
Canvas.onTouch() TouchEvent接口 TouchEvent接口增强,新增pressure字段 可使用pressure实现压感画笔
CanvasRenderingContext2D 基础2D绘制API 新增filter属性(CSS滤镜) 优先使用原生filter替代手动像素操作
ImageData 标准像素数据 新增colorSpace字段 需处理色彩空间转换
PixelMap 基础图片操作 新增effectKit集成 滤镜操作优先使用effectKit
taskpool.Task 基础任务池 支持优先级和任务组 复杂滤镜可拆分为任务组并行执行

行为变更

  • Canvas硬件加速默认开启:HarmonyOS 6.0中Canvas默认使用GPU渲染,绘制性能大幅提升,但某些混合模式的行为可能与软件渲染不同,需要重新测试
  • ImageData内存布局变更:6.0中ImageData的data属性返回的是SharedArrayBuffer,可以直接在Worker间零拷贝传递,但不能再直接修改
  • 触摸采样率提升:6.0支持240Hz触摸采样,画笔工具需要做更激进的点采样过滤,否则数据量过大

适配代码

// HarmonyOS 6适配:压感画笔
private handlePressureBrushDraw(event: TouchEvent, point: Point) {
  const touch = event.touches[0]
  // HarmonyOS 6新增pressure字段
  const pressure = (touch as any).pressure ?? 0.5
  const dynamicWidth = this.baseWidth * pressure * 2

  if (event.type === TouchType.Down) {
    this.tempContext.beginPath()
    this.tempContext.lineWidth = dynamicWidth
    this.tempContext.strokeStyle = this.strokeColor
    this.tempContext.moveTo(point.x, point.y)
  } else if (event.type === TouchType.Move) {
    this.tempContext.lineWidth = dynamicWidth
    this.tempContext.lineTo(point.x, point.y)
    this.tempContext.stroke()
  }
}

// HarmonyOS 6适配:使用原生CSS滤镜
private applyNativeFilter() {
  // 6.0支持Canvas原生filter属性
  this.mainContext.filter = 'grayscale(1)'  // 灰度
  this.mainContext.filter = 'sepia(1)'      // 复古
  this.mainContext.filter = 'blur(5px)'     // 模糊
  this.mainContext.filter = 'brightness(1.5)' // 亮度
  this.mainContext.filter = 'contrast(2)'   // 对比度
  // 绘制时自动应用滤镜
  this.compositeAndRender()
  // 清除滤镜
  this.mainContext.filter = 'none'
}

六、总结

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

绘图工具和图片编辑器的开发,表面上看是在"画画",实际上是在和触摸事件、像素数据、内存管理、渲染性能这四大对手博弈。画笔的平滑度取决于贝塞尔曲线的实现质量,橡皮擦的正确性取决于混合模式的理解深度,图层系统的稳定性取决于内存管理的严谨程度,而滤镜的流畅度取决于多线程的运用能力。

记住一个核心原则:永远不要在UI线程做像素级操作。无论是滤镜、选区还是图层合成,只要是遍历像素的活儿,统统扔到子线程去。UI线程只负责接收触摸事件和最终渲染,这样你的编辑器才能既流畅又稳定。

另外,架构设计先行。不要一上来就写画笔代码,先把交互层、工具层、图层系统、渲染引擎的接口定义好,再逐层实现。你会发现,好的架构让后续的功能扩展变得水到渠成——加一个新画笔?实现工具接口就行。加一个新滤镜?写一个像素处理函数就行。这就是架构的力量。

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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