欢迎加入开源鸿蒙 PC社区

https://harmonypc.csdn.net/

效果截图

在这里插入图片描述

第5篇:手写画布实现

系列教程导航

篇号 标题 状态
01 环境搭建与项目创建
02 数据模型与单词仓库
03 主入口页面与导航结构
04 极速划词页面实现
05 手写画布实现 本篇
06 百度OCR手写识别接入 下一篇
07 答案比对与反馈UI
08 单词切换与底部导航
09 词根分解与水印展示
10 项目总结与优化方向

源码仓库https://gitcode.com/qq_33247427/englishProject

一、Canvas 组件基础

1.1 创建 Canvas

ArkUI 提供了 Canvas 组件用于 2D 绑图,API 与 Web 标准的 Canvas 2D Context 基本一致:

// 1. 创建渲染上下文
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

// 2. 在 UI 中使用
Canvas(this.canvasCtx)
  .id('speedDictCanvas')    // 必须设置 id,后续截图需要
  .width('100%')
  .height('100%')
  .onReady(() => {
    // Canvas 准备就绪,可以开始绑图
    this.setupBrush();
  })
  .onTouch((event: TouchEvent) => {
    // 处理触摸事件
  })
1.2 RenderingContextSettings
new RenderingContextSettings(true)  // true = 开启抗锯齿

开启抗锯齿后,线条边缘更平滑,手写体验更好。

1.3 Canvas 的 id 属性

.id('speedDictCanvas') 非常重要——后续使用 componentSnapshot.get('speedDictCanvas') 截图时需要通过这个 id 找到组件。

二、画笔配置

2.1 基本画笔设置
setupBrush() {
  this.canvasCtx.strokeStyle = '#1A1A1A';  // 笔色(近黑色)
  this.canvasCtx.lineWidth = 4;            // 笔宽 4px
  this.canvasCtx.lineCap = 'round';        // 线条端点圆形
  this.canvasCtx.lineJoin = 'round';       // 线条连接处圆形
}
2.2 各属性效果对比
属性 效果
lineCap 'butt' 方形端点(默认)
lineCap 'round' 圆形端点(推荐)
lineCap 'square' 方形但多出半个线宽
lineJoin 'miter' 尖角连接(默认)
lineJoin 'round' 圆角连接(推荐)
lineJoin 'bevel' 斜角连接

对于手写场景,round + round 的组合最自然,笔画起止和转折处都是圆润的。

2.3 笔宽选择
场景 推荐笔宽 说明
英文单词 3-5 px 字母笔画较细
中文汉字 5-8 px 笔画需要更粗
签名 2-3 px 流畅细线

三、触摸绑图(核心:防卡顿)

3.1 错误实现(会越来越卡)

很多教程给出的写法:

// ❌ 错误写法
.onTouch((event: TouchEvent) => {
  const touch = event.touches[0];
  switch (event.type) {
    case TouchType.Down:
      this.canvasCtx.beginPath();
      this.canvasCtx.moveTo(touch.x, touch.y);
      break;
    case TouchType.Move:
      this.canvasCtx.lineTo(touch.x, touch.y);
      this.canvasCtx.stroke();  // 每次都重绘整条路径!
      break;
    case TouchType.Up:
      break;
  }
})

问题stroke() 会绘制当前 path 中的所有线段。随着 Move 事件不断触发,path 越来越长(可能几百上千个点),每次 stroke() 都要重绘从起点到当前点的所有线段,导致帧率急剧下降。

3.2 正确实现(每段独立绘制)
// ✅ 正确写法
.onTouch((event: TouchEvent) => {
  const touch = event.touches[0];
  switch (event.type) {
    case TouchType.Down:
      this.isDrawing = true;
      this.canvasCtx.beginPath();
      this.canvasCtx.moveTo(touch.x, touch.y);
      break;
    case TouchType.Move:
      if (this.isDrawing) {
        this.canvasCtx.lineTo(touch.x, touch.y);
        this.canvasCtx.stroke();
        // 关键:重新开始路径
        this.canvasCtx.beginPath();
        this.canvasCtx.moveTo(touch.x, touch.y);
      }
      break;
    case TouchType.Up:
      this.isDrawing = false;
      break;
  }
})

原理:每次 Move 后立即 beginPath() + moveTo(),将当前点作为新路径的起点。这样每次 stroke() 只绘制最新的一小段线条(从上一个点到当前点),性能恒定,不会随书写时间增长而变慢。

3.3 性能对比
方案 书写 100 个点后的 stroke 开销 书写 1000 个点后
错误写法 绘制 100 段线条 绘制 1000 段线条
正确写法 绘制 1 段线条 绘制 1 段线条

四、三层 Stack 结构

4.1 为什么需要三层

默写单词时,画布上需要同时展示:

  1. 白色背景 — OCR 截图需要白底才能识别
  2. 水印文字 — 显示当前单词供参考
  3. 手写笔迹 — 用户的书写内容

如果只用一个 Canvas:

  • Canvas 背景白色 → 水印被遮住
  • Canvas 背景透明 → OCR 截图没有对比度

解决方案:三层 Stack 叠加。

4.2 层级结构
Stack({ alignContent: Alignment.TopStart }) {
  // 第 1 层:白色底(最底层)
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')

  // 第 2 层:水印文字(中间层)
  if (this.showWatermark && this.currentWord !== null) {
    Column({ space: 12 }) {
      Text(this.currentWord.english)
        .fontSize(48)
        .fontColor('#E8EDE0')       // 极浅色
        .fontWeight(FontWeight.Bold)
        .fontStyle(FontStyle.Italic)
      Text(this.currentWord.phonetic)
        .fontSize(18)
        .fontColor('#D0D8C8')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .hitTestBehavior(HitTestMode.Transparent)  // 关键!
  }

  // 第 3 层:Canvas(最顶层,透明背景)
  Canvas(this.canvasCtx)
    .id('speedDictCanvas')
    .width('100%')
    .height('100%')
    .backgroundColor(Color.Transparent)  // 透明!
    .onReady(() => { this.setupBrush(); })
    .onTouch(...)
}
4.3 关键属性解析

hitTestBehavior(HitTestMode.Transparent)

水印层在 Canvas 下方,但 Stack 中后面的子元素在上层。如果水印层拦截了触摸事件,Canvas 就收不到了。HitTestMode.Transparent 让触摸事件穿透水印层,传递给下方的 Canvas。

等等——Canvas 在最顶层(Stack 中最后声明的在最上面),水印在中间,所以触摸事件先到 Canvas,不需要穿透。那为什么还要加 hitTestBehavior

答:防止水印层在某些情况下(如条件渲染重新插入时)意外拦截事件。加上这个属性是防御性编程。

backgroundColor(Color.Transparent)

Canvas 默认背景是白色。设为透明后,Canvas 只显示笔迹,水印文字从下层透出来。

4.4 视觉效果
用户看到的效果:
┌─────────────────────────────┐
│                             │
│      electrical             │  ← 浅色水印(可见)
│      /ɪˈlektrɪkl/          │
│                             │
│   ████████                  │  ← 用户笔迹(覆盖在水印上方)
│        ████                 │
│                             │
└─────────────────────────────┘

五、清空画布

5.1 clearCanvas 方法
clearCanvas() {
  const w = this.canvasCtx.width;
  const h = this.canvasCtx.height;
  this.canvasCtx.clearRect(0, 0, w, h);  // 清除所有像素
  this.setupBrush();                      // 重新设置画笔
  this.feedbackText = '';                  // 清除反馈
  this.showAnswer = false;
}
5.2 为什么不用 fillRect 填白?

因为 Canvas 背景是透明的,clearRect 会将像素清除为透明,水印自然从下层显示出来。如果用 fillRect('#FFFFFF') 填白,会遮住水印。

六、componentSnapshot 截图

6.1 截图原理

componentSnapshot.get(id) 会将指定 id 的组件及其所有子层渲染为一张 PixelMap 图片。由于我们的 Stack 包含白底 + 水印 + 笔迹,截图结果是一张白底上有笔迹的图片(水印颜色极浅,不影响 OCR)。

import { componentSnapshot } from '@kit.ArkUI';
import { image } from '@kit.ImageKit';

const pixelMap: image.PixelMap = await componentSnapshot.get('speedDictCanvas');
6.2 注意事项
  • 截图是异步操作,需要 await
  • 截图分辨率取决于设备像素密度(高分屏可能很大)
  • 截图包含 Canvas 的透明背景——但因为 Stack 下面有白底层,最终截图是白底的

七、水印开关

7.1 状态控制
@State showWatermark: boolean = true;

// 在工具栏中
Row({ space: 6 }) {
  Text('水印').fontSize(12).fontColor('#6B7280')
  Toggle({ type: ToggleType.Switch, isOn: this.showWatermark })
    .selectedColor('#8B9D6B')
    .width(40)
    .height(22)
    .onChange((isOn: boolean) => { this.showWatermark = isOn; })
}
7.2 条件渲染
if (this.showWatermark && this.currentWord !== null) {
  // 渲染水印
}

showWatermark 为 false 时,水印层不渲染,用户看到的是纯白画布。

八、画布容器样式

Stack(...)
  .layoutWeight(1)          // 占满剩余垂直空间
  .width('100%')
  .backgroundColor('#FFFFFF')
  .borderRadius(4)          // 微圆角
  .clip(true)               // 裁剪超出内容
  .border({ width: 1, color: '#E5E7EB' })
  • borderRadius(4):微圆角,不要太圆,保持工具感
  • clip(true):笔迹画到边缘时不会溢出
  • 整个 DictationContent 有 padding({ left: 30, right: 30 }),画布距离页面边缘 30px

九、本篇小结

通过本篇教程,我们完成了:

  • 理解了 Canvas 组件的创建和配置
  • 掌握了画笔属性设置(lineCap、lineJoin、lineWidth)
  • 解决了画笔卡顿问题(每段独立 stroke)
  • 实现了三层 Stack 结构(白底 + 水印 + 透明画布)
  • 理解了 hitTestBehavior 触摸穿透机制
  • 实现了清空画布和水印开关
  • 了解了 componentSnapshot 截图原理

下一篇预告

第 6 篇:百度 OCR 手写识别接入 — 我们将把画布截图发送到百度手写文字识别 API,获取识别结果,实现"写完即识别"的核心功能。

Logo

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

更多推荐