05手写画布实现-鸿蒙PC端Electron开发

欢迎加入开源鸿蒙 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 基本一致:

typescript 复制代码
// 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
typescript 复制代码
new RenderingContextSettings(true)  // true = 开启抗锯齿

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

1.3 Canvas 的 id 属性

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

二、画笔配置

2.1 基本画笔设置
typescript 复制代码
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 错误实现(会越来越卡)

很多教程给出的写法:

typescript 复制代码
// ❌ 错误写法
.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 正确实现(每段独立绘制)
typescript 复制代码
// ✅ 正确写法
.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 层级结构
typescript 复制代码
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 方法
typescript 复制代码
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)。

typescript 复制代码
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 状态控制
typescript 复制代码
@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 条件渲染
typescript 复制代码
if (this.showWatermark && this.currentWord !== null) {
  // 渲染水印
}

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

八、画布容器样式

typescript 复制代码
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,获取识别结果,实现"写完即识别"的核心功能。

相关推荐
glustful36 分钟前
语音唤醒技术选型与实践:从开源方案到自主训练
开源·ai智能·语音唤醒·语音服务
OpsEye1 小时前
直连大模型API、开源网关、商用AI管理平台,该如何抉择
人工智能·开源
ClouGence2 小时前
2026 年 4 款数据库管理工具推荐:免费、开源、付费怎么选?
数据库·后端·开源
小雨青年2 小时前
【HarmonyOS 7 沉浸光感深度实战】10 通用组件封装与现有项目接入
华为·harmonyos
YM52e2 小时前
蒙特卡洛模拟器:鸿蒙 Canvas 上的大数定律与中心极限定理实证
华为·harmonyos
人工智能研究所2 小时前
正面硬刚 Seedance 2.5, 开源视频模型:LTX-2.5 用 6.8 秒生成 10 秒视频
人工智能·开源·音视频·文生视频·seedance·ltx-2.5
ZJU_统一阿萨姆3 小时前
【推理优化进阶】图编译与运行时:动态形状、CUDA Graph 与内存规划
开发语言·人工智能·语言模型·架构·开源
小玮看世界3 小时前
AtomGit 429 报错引发的思考:从“未授权限流”看开源平台的“浏览权”博弈
开源·github
敢敢是只喵i3 小时前
BailingHub 开源版安装完成后,怎样跑通第一条可验证的 Agent 任务?
开源
Dovis(誓平步青云)3 小时前
DevEco Studio 6.1.1 Windows 安装实录:从下载校验到首次启动
android·开发语言·数据库·人工智能·windows·harmonyos