手把手教你写个涂鸦板:鸿蒙原生开发初体验

嘿,朋友们!今天咱们来搞点有意思的——用鸿蒙写一个涂鸦板!

别被"鸿蒙原生开发"这几个字吓到,其实没那么难。跟着我的节奏走,保证你能跑起来一个能画画的小应用。

咱们要做什么?

就是一个简单的涂鸦板:

  • 手指在屏幕上滑,就能画出线条
  • 可以选8种颜色
  • 可以调笔刷粗细(细、中、粗三档)
  • 支持撤销和重做
  • 能清空画布
  • 还能切换深色/浅色背景

听起来功能挺多的?别急,咱们一步步来。


第一步:先把项目建起来

打开 DevEco Studio(华为官方的 IDE,写鸿蒙应用必备),新建项目:

Empty Ability 模板,这个最干净,啥都没有,正好咱们从头写。

填项目信息:

  • Project name: MyApplication(名字随便起)
  • Bundle name: com.example.myapplication
  • Compatible SDK: 6.1.0(23)
  • Target SDK: 6.1.1(24)

等 Gradle 同步完,项目就建好了。看一下目录结构:

MyApplication/
├── AppScope/
│   └── app.json5          # 应用配置
├── entry/                  # 主模块
│   └── src/main/
│       ├── ets/           # 代码在这写
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets  # 入口
│       │   └── pages/
│       │       └── Index.ets         # 主页面(重点!)
│       └── resources/     # 资源文件
└── build-profile.json5

咱们主要就改一个文件:entry/src/main/ets/pages/Index.ets


第二步:理解一下核心逻辑

涂鸦板的原理其实很简单:

  1. 用户手指按下去 → 记录起点
  2. 手指移动 → 一边记录新点,一边画线
  3. 手指抬起 → 把这条线保存起来

所以咱们需要存:

  • 所有的线条(每条线是一堆点的集合)
  • 每条线的颜色和粗细

用代码描述就是:

interface Point {
  x: number
  y: number
}

interface DrawAction {
  points: Point[]    // 这条线的所有点
  color: string      // 颜色
  width: number      // 粗细
}

第三步:开始写代码

3.1 状态定义

打开 Index.ets,先定义咱们需要的状态变量:

@Entry
@Component
struct Index {
  // Canvas 上下文,绑定画布的
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D()
  
  // 所有画过的线
  @State lines: DrawAction[] = []
  
  // 撤销栈(重做用)
  @State undoStack: DrawAction[] = []
  
  // 当前颜色和粗细
  @State currentColor: string = '#333333'
  @State currentWidth: number = 4
  
  // 正在画的标记
  @State isDrawing: boolean = false
  @State bgWhite: boolean = true  // 背景色

  // 当前这条线(临时的)
  private currentLine: Point[] = []
}

@State 这个装饰器很重要!加上它,变量变了 UI 就会跟着变。不加?那你改了数据界面也不动。

3.2 画线的方法

drawLine(action: DrawAction): void {
  const ctx = this.context
  if (action.points.length < 2) return  // 点太少画不了
  
  ctx.beginPath()
  ctx.lineCap = 'round'   // 线头圆的
  ctx.lineJoin = 'round'  // 拐角圆的
  ctx.strokeStyle = action.color
  ctx.lineWidth = action.width
  
  // 从第一个点开始,连到后面所有点
  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()
}

这就是 Canvas 的经典画线套路:moveTo 起点 → lineTo 下一个点 → stroke() 真正画出来。

3.3 处理触摸事件

这是重点!三个事件要处理:

// 手指按下
handleTouchStart(event: TouchEvent): void {
  const touch = event.touches[0]
  this.isDrawing = true
  this.currentLine = [{ x: touch.x, y: touch.y }]
}

// 手指移动
handleTouchMove(event: TouchEvent): void {
  if (!this.isDrawing) return
  const touch = event.touches[0]
  this.currentLine.push({ x: touch.x, y: touch.y })
  
  // 实时画最新的一段(性能优化)
  const points = this.currentLine
  if (points.length < 2) return
  
  const ctx = this.context
  const last = points[points.length - 1]
  const prev = points[points.length - 2]
  
  ctx.beginPath()
  ctx.strokeStyle = this.currentColor
  ctx.lineWidth = this.currentWidth
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  ctx.moveTo(prev.x, prev.y)
  ctx.lineTo(last.x, last.y)
  ctx.stroke()
}

// 手指抬起
handleTouchEnd(): void {
  if (!this.isDrawing || this.currentLine.length < 2) {
    this.isDrawing = false
    this.currentLine = []
    return
  }
  
  // 保存这条线
  const action: DrawAction = {
    points: [...this.currentLine],
    color: this.currentColor,
    width: this.currentWidth
  }
  this.lines = [...this.lines, action]  // 注意!要重新赋值才能触发更新
  this.undoStack = []  // 新操作清空重做栈
  this.isDrawing = false
  this.currentLine = []
}

小技巧:在 handleTouchMove 里,咱们只画最新的一段,不重绘所有线。这样画得快,不卡顿。


第四步:撤销和重做

这个用两个栈实现,经典套路:

handleUndo(): void {
  if (this.lines.length === 0) return
  const last = this.lines[this.lines.length - 1]
  this.undoStack = [...this.undoStack, last]
  this.lines = this.lines.slice(0, -1)
  this.redrawAll()
}

handleRedo(): void {
  if (this.undoStack.length === 0) return
  const last = this.undoStack[this.undoStack.length - 1]
  this.lines = [...this.lines, last]
  this.undoStack = this.undoStack.slice(0, -1)
  this.redrawAll()
}

撤销 = 把最后一条线从主栈移到撤销栈
重做 = 从撤销栈拿回来


第五步:搞定 UI 界面

5.1 整体布局

build() {
  Column() {
    // 顶部工具栏
    // ...
    
    // 画布
    Stack() {
      Canvas(this.context)
        .width('100%')
        .height(400)
        .backgroundColor(this.bgWhite ? '#FFFFFF' : '#212121')
        .borderRadius(12)
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Down) {
            this.handleTouchStart(event)
          } else if (event.type === TouchType.Move) {
            this.handleTouchMove(event)
          } else if (event.type === TouchType.Up) {
            this.handleTouchEnd()
          }
        })
    }
    
    // 颜色选择
    // ...
    
    // 粗细选择
    // ...
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FFF8E1')
}

5.2 颜色选择器

private readonly colors: string[] = [
  '#333333', '#E53935', '#FB8C00', '#FDD835',
  '#43A047', '#1E88E5', '#8E24AA', '#FFFFFF'
]

Row() {
  ForEach(this.colors, (color: string) => {
    Stack() {
      Circle()
        .width(32).height(32)
        .fill(color)
        .stroke(color === '#FFFFFF' ? '#DDD' : color)
      
      if (color === this.currentColor) {
        Circle()
          .width(38).height(38)
          .fill('none')
          .stroke('#5D4037')
          .strokeWidth(2.5)
      }
    }
    .onClick(() => { this.currentColor = color })
  }, (color: string) => color)
}

5.3 粗细选择

private readonly widths: number[] = [3, 6, 12]
private readonly widthLabels: string[] = ['细', '中', '粗']

Row() {
  ForEach(this.widths, (width: number, index: number) => {
    Column() {
      Circle()
        .width(width * 2)
        .height(width * 2)
        .fill(this.currentColor)
      
      Text(this.widthLabels[index])
        .fontSize(12)
        .fontColor(this.currentWidth === width ? '#5D4037' : '#BBB')
    }
    .backgroundColor(this.currentWidth === width ? '#EFEBE9' : 'transparent')
    .onClick(() => { this.currentWidth = width })
  }, (width: number) => width.toString())
}

第六步:深色模式

一键切换背景色:

toggleBg(): void {
  this.bgWhite = !this.bgWhite
  this.redrawAll()
}

踩坑提醒

坑一:数组改了界面不动

// ❌ 这样不行
this.lines.push(action)

// ✅ 要这样
this.lines = [...this.lines, action]

ArkTS 的响应式要求你给数组一个新的引用,直接 push 不触发更新。

坑二:白色按钮看不清

白色圆形在浅色背景下几乎看不见,加个灰色边框:

.stroke(color === '#FFFFFF' ? '#DDD' : color)

坑三:撤销后再画,重做丢了

这是正常的!新操作清空撤销栈是正确行为,不然重做就乱了。



在这里插入图片描述


总结

这篇教程带你从零写了个涂鸦板,核心就是:

  • Canvas 画布 + CanvasRenderingContext2D
  • 触摸事件三件套:Down、Move、Up
  • @State 响应式更新
  • 栈结构实现撤销/重做

入门鸿蒙,这个小项目挺合适的。有问题评论区见!

原创不易,转载请注明出处 👋

Logo

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

更多推荐