在 HarmonyOS 手机上从零实现一个手绘涂鸦板——Canvas 绘制原理与触摸交互实践

前言

不知道你有没有留意过一个现象:现在的手机备忘录里几乎都自带涂鸦功能,开会时随手画个示意图,比打字快得多。但你有没有想过,这个功能背后到底是怎么实现的?手指在玻璃上划过的轨迹,是如何被"翻译"成屏幕上一条条彩色线条的?

这篇文章打算用 HarmonyOS 原生的 Canvas 组件,从零开始搭一个能跑在华为手机模拟器上的简易画板。代码不复杂,但每一步的原理我都会拆开来讲清楚。四个核心功能------画线、换色、调粗细、清空------写完之后,你对 ArkTS 里自定义绘制这件事会有一个非常具体的认知。

开发环境是 DevEco Studio 6.0.2 Release,模拟器用的是华为手机镜像。你可以跟着一步步来,最后一定会得到一个能真正在模拟器里用手指画画的 App。

一、画画之前,先认识两个"角色"

在做任何绘制操作之前,我们需要先搞清楚 HarmonyOS 里 Canvas 的运作模式。它和 Web 端的 Canvas 很像,但封装方式略有不同。鸿蒙里的绘制链条由两个关键对象组成:

第一个是 RenderingContextSettings。

它负责配置绘制的"全局参数",其中最重要的一个是 antialias,也就是抗锯齿。这个值设为 true 之后,你画出来的线条边缘会被自动柔化,不会出现那种像素感很强的锯齿边。对于手绘涂鸦来说,这个开关一定要打开。

第二个是 CanvasRenderingContext2D。

这个对象就是你的"画笔"。所有的绘图动作------画线、填充、清空、变换------都是通过它来调用的。你可以把它想象成一个握在手里的工具箱,需要什么操作就从里面拿什么工具。

创建这两个对象的代码非常简洁:

复制代码
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

之后,我们只需要把这个 context 传给页面上的 Canvas 组件,画板就有了"被绘制"的能力。需要注意的是:Canvas 组件本身只是一个容器,它不知道自己该画什么,所有绘制逻辑都在 context****的方法调用中完成。 这种设计把 UI 布局和绘图逻辑分开了,代码结构会清爽很多。

二、手指滑过屏幕,我们得到了什么

画板的核心交互逻辑很简单:手指按下去的时候开始记录起点,手指滑动的时候不断追加新的坐标点,然后把前后两个点用线连起来。这套流程对应三个触摸事件:

  • onTouch 里的 TouchType.Down:手指按下
  • TouchType.Move:手指移动
  • TouchType.Up:手指抬起

但在鸿蒙的 Canvas 组件上监听触摸事件,和我们平时在 ButtonText 上监听不太一样。Canvas 提供了专门的 onReady 回调,你需要在回调里拿到 CanvasRenderingContext2D 之后,才能进一步绑定触摸逻辑。

这里有一个容易踩坑的地方:触摸事件给出的坐标是相对整个画布左上角的,而 Canvas 的绘制坐标系原点也在左上角,所以坐标值可以直接拿来用,不需要额外转换。这一点比某些其他框架要友好很多。

三、路径(Path)是怎么把点连成线的

当你在屏幕上从 A 点划到 B 点,程序不是简单地在这两点之间画一条直线就完事了。如果那样做,画出来的线条会是一段一段的折线,转折处会有明显的断点。

正确的做法是利用 路径(Path) 的概念。你可以把路径想象成一支笔在纸上连续移动留下的轨迹,而不是一段一段的线段拼接。

在 Canvas API 里,画一条完整的手绘线分为这样几个步骤:

  1. beginPath():告诉画布"我要开始画一条新的路径了"
  2. moveTo(x, y):把笔尖移动到起点位置
  3. lineTo(x, y):从当前位置画一条直线到目标位置
  4. stroke():真正把路径渲染出来

手指按下时,我们调用 beginPath() 并用 moveTo() 记录起点。手指移动时,每一次收到新的坐标,就调用 lineTo() 连接上一个点,然后立即 stroke()。手指抬起时,可以再调用一次 stroke() 确保最后一段被绘制完成。

这里有一个性能上的细节:stroke() 这个操作是比较重的,如果每一次移动事件都完整执行一遍 beginPath → moveTo → lineTo → stroke 的流程,当手指快速滑动、事件频率很高的时候,可能会有卡顿感。优化方式是:在 Move 事件里只调用 lineTo()stroke(),不重复调用 beginPath(),因为路径本身是连续追加的。

代码里大概是这样的结构:

复制代码
case TouchType.Down:
  this.context.beginPath();
  this.context.moveTo(touchInfo.x, touchInfo.y);
  break;
case TouchType.Move:
  this.context.lineTo(touchInfo.x, touchInfo.y);
  this.context.stroke();
  break;
case TouchType.Up:
  this.context.lineTo(touchInfo.x, touchInfo.y);
  this.context.stroke();
  this.context.closePath();
  break;

注意最后用了一个 closePath(),这是可选的,它的作用是把路径的终点和起点连接起来形成一个闭合图形。在手绘场景下,我们其实不需要闭合,所以不调用也可以,但习惯上加上也没坏处。

四、画笔的"外观"是怎样被改变的

画板如果只能画黑色的细线,那和备忘录的默认签名功能没什么区别。一个能称得上"小画板"的工具,至少得支持换颜色和调粗细。这两个功能在 Canvas 里是通过设置 context 的属性来实现的。

换颜色 非常简单,只需要在调用 stroke() 之前设置:

复制代码
this.context.strokeStyle = '#FF5733'; // 橙红色

strokeStyle 接受 CSS 颜色字符串,十六进制、RGB、颜色名称都可以。这样一来,后续所有路径的描边都会使用这个颜色。

调粗细 则对应 lineWidth 属性,单位是像素:

复制代码
this.context.lineWidth = 5; // 5 像素粗

这两个属性是"状态型"的------你设置一次之后,后续的绘制都会沿用这个值,直到你再次修改它。这也就意味着,当我们通过滑块或者颜色选择器调整参数时,只需要更新 context 对应的属性值即可,不需要在每次绘制时重复设置。

从用户交互的角度看,颜色选择可以用鸿蒙自带的 ColorPicker 组件,粗细调节用 Slider。这两者都是声明式 UI 里非常成熟的控件,监听它们的 onChange 事件,把新值同步到 context 上就行了。

复制代码
Slider({
  value: this.brushSize,
  min: 1,
  max: 20,
  step: 1
})
.onChange((value: number) => {
  this.brushSize = value;
  this.context.lineWidth = value;
});

颜色选择稍微复杂一点,因为 ColorPicker 返回的是 Color 对象,需要转成字符串:

复制代码
ColorPicker({
  color: Color.Black
})
.onChange((color: Color) => {
  this.context.strokeStyle = colorToString(color);
});

五、"清空画布"这件事为什么不是简单的涂白

很多初学者会觉得清空画布很简单------用一个大白色矩形把整个画布盖住不就行了?这个思路在逻辑上没错,但在实现上有更优雅的做法。

Canvas 提供了专门的方法 clearRect(x, y, width, height),它的作用是清除指定矩形区域内的所有像素,让这部分区域恢复成完全透明的初始状态。这个方法的性能远高于用白色矩形去覆盖,因为它直接操作像素缓冲区,不涉及绘制管线的渲染流程。

对于清空整个画布的需求,我们只需要获取画布的宽高,然后调用:

复制代码
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);

这里有一个小细节需要留意:画布的宽高不是我们随手写的数值,而是需要从 Canvas 组件的尺寸中获取。在鸿蒙里,可以通过 Canvas 组件的 onAreaChange 事件拿到实际渲染区域的宽高,保存下来供后续使用。

另外,清空画布之后,之前设置的 strokeStylelineWidth 这些状态并不会丢失,它们依然保留在 context 上。这意味着清空之后你继续画,颜色和粗细还是之前设置的样子,符合用户预期。

六、把零件组装起来------完整代码

前面已经把每个功能模块的原理讲清楚了,下面给出完整的页面代码。这份代码可以直接复制到 DevEco Studio 里新建的 ArkTS 页面中运行,记得在 main_pages.json 里把入口页面指向它。

复制代码
// pages/CanvasBoard.ets
import { drawing } from '@kit.ArkGraphics2D';

@Entry
@Component
struct CanvasBoard {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  
  @State private brushColor: string = '#000000';
  @State private brushSize: number = 5;
  @State private canvasWidth: number = 0;
  @State private canvasHeight: number = 0;
  
  private colorToHex(color: Color): string {
    // 将 Color 对象转换为 #RRGGBB 格式
    const r = Math.round(color.red * 255).toString(16).padStart(2, '0');
    const g = Math.round(color.green * 255).toString(16).padStart(2, '0');
    const b = Math.round(color.blue * 255).toString(16).padStart(2, '0');
    return `#${r}${g}${b}`;
  }

  build() {
    Column() {
      // 工具栏
      Row() {
        // 颜色选择
        ColorPicker({ color: Color.Black })
          .width(40)
          .height(40)
          .onChange((color: Color) => {
            this.brushColor = this.colorToHex(color);
            this.context.strokeStyle = this.brushColor;
          })
        
        // 粗细滑块
        Slider({
          value: this.brushSize,
          min: 1,
          max: 30,
          step: 1
        })
          .width('50%')
          .margin({ left: 12, right: 12 })
          .onChange((value: number) => {
            this.brushSize = value;
            this.context.lineWidth = value;
          })
        
        // 粗细显示
        Text(`${this.brushSize}px`)
          .fontSize(14)
          .fontColor('#666')
          .width(40)
        
        // 清空按钮
        Button('清空')
          .fontSize(14)
          .margin({ left: 12 })
          .onClick(() => {
            this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
          })
      }
      .width('100%')
      .padding(12)
      .justifyContent(FlexAlign.Start)
      .alignItems(VerticalAlign.Center)

      // 画布区域
      Canvas(this.context)
        .width('100%')
        .height('80%')
        .backgroundColor('#F5F5F5')
        .onReady(() => {
          // 初始化画布样式
          this.context.strokeStyle = this.brushColor;
          this.context.lineWidth = this.brushSize;
          this.context.lineCap = 'round';
          this.context.lineJoin = 'round';
        })
        .onAreaChange((oldValue: Area, newValue: Area) => {
          // 记录画布实际尺寸
          this.canvasWidth = vp2px(newValue.width as number);
          this.canvasHeight = vp2px(newValue.height as number);
        })
        .onTouch((event: TouchEvent) => {
          const touchInfo = event.touches[0];
          const x = vp2px(touchInfo.x);
          const y = vp2px(touchInfo.y);
          
          switch (event.type) {
            case TouchType.Down:
              this.context.beginPath();
              this.context.moveTo(x, y);
              break;
            case TouchType.Move:
              this.context.lineTo(x, y);
              this.context.stroke();
              break;
            case TouchType.Up:
              this.context.lineTo(x, y);
              this.context.stroke();
              this.context.closePath();
              break;
          }
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }
}

代码里用到了 vp2px 这个转换函数,因为触摸事件返回的坐标单位是 vp(虚拟像素),而 Canvas 绘制方法接受的坐标单位是 px(物理像素)。在 onTouch 中如果不做转换,在部分设备上会出现偏移。同样地,onAreaChange 里获取的宽高也需要转成 px 再存入 canvasWidthcanvasHeight,这样清空操作才能覆盖整块画布。

另外,初始化时还设置了两个容易被忽略但体验影响很大的属性:

  • lineCap = 'round':让线条的端点变成圆头,这样点一下屏幕的时候会留下一个小圆点,而不是一个难看的方头。
  • lineJoin = 'round':让线条转折处变得平滑,画折线时不会出现尖刺。

运行效果

把代码贴进项目,编译运行,华为手机模拟器上会显示一个白底画板,顶部有一排工具栏。点击颜色圆圈可以调出色盘选色,拖动滑块能看到当前粗细值的变化。手指在画布区域滑动时,会留下对应颜色和粗细的线条,支持连续绘制,中间不会断线。点击"清空"按钮,画布恢复空白,但颜色和粗细设定保持不变。

整个过程没有卡顿,线条边缘平滑,颜色切换即时响应。虽然功能简单,但作为 Canvas 入门的学习案例已经足够完整。

总结

写这个小画板的过程中,我们实际踩了一遍 HarmonyOS 里 Canvas 绘制的完整链路:从创建上下文、监听触摸事件,到路径绘制、属性配置,再到区域清空。这些知识点看似基础,但任何一个做过复杂图形应用的人都知道,图形绘制的底层逻辑万变不离其宗,掌握了路径、状态和事件这三板斧,后面再去碰图表库、动画或者游戏引擎,都不会觉得陌生。

如果你有兴趣继续深入,下一步可以试试给画板加上"撤销"功能------那会涉及到快照保存和状态栈的管理,对理解 Canvas 的性能优化和内存管理会有新的启发。

相关推荐
想你依然心痛2 小时前
HarmonyOS 6(API 23)实战:基于 Face AR 情绪识别与 Body AR 手势控制的“情绪感知智能工作台“
华为·ar·harmonyos·悬浮导航·沉浸光感
HwJack202 小时前
HarmonyOS 开发中Web 组件渲染进程崩溃后的“起死回生”
前端·华为·harmonyos
前端不太难2 小时前
鸿蒙游戏架构进阶:如何拆分 Store 与 System?
游戏·架构·harmonyos
liulian09162 小时前
【Flutter for OpenHarmony第三方库】Flutter for OpenHarmony 数据统计与用户行为分析功能适配与实现指南
flutter·华为·学习方法·harmonyos
想你依然心痛2 小时前
HarmonyOS 6(API 23)分布式实战:基于悬浮导航与沉浸光感的“光影协创“跨设备白板系统
分布式·wpf·harmonyos·悬浮导航·沉浸光感
nashane12 小时前
HarmonyOS 6学习:旋转动画优化与长截图性能调优——打造丝滑交互体验的深度实践
学习·交互·harmonyos·harmonyos 5
南村群童欺我老无力.17 小时前
鸿蒙自定义组件接口设计的向后兼容陷阱
华为·harmonyos
ZC跨境爬虫17 小时前
UI前端美化技能提升日志day7:(原生苹方字体全局适配+合规页脚完整像素级落地)
前端·javascript·ui·html·交互
liulian091617 小时前
Flutter 跨平台路由与状态管理:go_router 与 Riverpod 的 OpenHarmony总结
flutter·华为·学习方法·harmonyos