05手写画布实现-鸿蒙PC端Electron开发
本文介绍了在开源鸿蒙PC社区中实现手写画布功能的关键技术。主要内容包括: Canvas组件基础使用,包括创建渲染上下文、抗锯齿设置和ID属性配置 画笔参数优化,详细说明了线条端点、连接处样式和笔宽选择 触摸绘图的核心优化方案,通过分段绘制解决性能卡顿问题 三层Stack结构设计,实现白底背景、水印文字和透明画布的叠加显示 关键属性hitTestBehavior的作用,确保触摸事件穿透水印层到达画布
欢迎加入开源鸿蒙 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 为什么需要三层
默写单词时,画布上需要同时展示:
- 白色背景 — OCR 截图需要白底才能识别
- 水印文字 — 显示当前单词供参考
- 手写笔迹 — 用户的书写内容
如果只用一个 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,获取识别结果,实现"写完即识别"的核心功能。
更多推荐




所有评论(0)