05手写画布实现-鸿蒙PC端Electron开发
欢迎加入开源鸿蒙 PC社区
https://harmonypc.csdn.net/
效果截图
第5篇:手写画布实现
系列教程导航
| 篇号 | 标题 | 状态 |
|---|---|---|
| 01 | 环境搭建与项目创建 | ✅ |
| 02 | 数据模型与单词仓库 | ✅ |
| 03 | 主入口页面与导航结构 | ✅ |
| 04 | 极速划词页面实现 | ✅ |
| 05 | 手写画布实现 | 本篇 |
| 06 | 百度OCR手写识别接入 | 下一篇 |
| 07 | 答案比对与反馈UI | |
| 08 | 单词切换与底部导航 | |
| 09 | 词根分解与水印展示 | |
| 10 | 项目总结与优化方向 |
源码仓库:https://atomgit.com/qq_33247427/englishProject
一、Canvas 组件基础
1.1 创建 Canvas
ArkUI 提供了Canvas组件用于 2D 绑图,API 与 Web 标准的 Canvas 2D Context 基本一致:
// 1. 创建渲染上下文privatesettings:RenderingContextSettings=newRenderingContextSettings(true);privatecanvasCtx:CanvasRenderingContext2D=newCanvasRenderingContext2D(this.settings);// 2. 在 UI 中使用Canvas(this.canvasCtx).id('speedDictCanvas')// 必须设置 id,后续截图需要.width('100%').height('100%').onReady(()=>{// Canvas 准备就绪,可以开始绑图this.setupBrush();}).onTouch((event:TouchEvent)=>{// 处理触摸事件})1.2 RenderingContextSettings
newRenderingContextSettings(true)// true = 开启抗锯齿开启抗锯齿后,线条边缘更平滑,手写体验更好。
1.3 Canvas 的 id 属性
.id('speedDictCanvas')非常重要——后续使用componentSnapshot.get('speedDictCanvas')截图时需要通过这个 id 找到组件。
二、画笔配置
2.1 基本画笔设置
setupBrush(){this.canvasCtx.strokeStyle='#1A1A1A';// 笔色(近黑色)this.canvasCtx.lineWidth=4;// 笔宽 4pxthis.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)=>{consttouch=event.touches[0];switch(event.type){caseTouchType.Down:this.canvasCtx.beginPath();this.canvasCtx.moveTo(touch.x,touch.y);break;caseTouchType.Move:this.canvasCtx.lineTo(touch.x,touch.y);this.canvasCtx.stroke();// 每次都重绘整条路径!break;caseTouchType.Up:break;}})问题:stroke()会绘制当前 path 中的所有线段。随着 Move 事件不断触发,path 越来越长(可能几百上千个点),每次stroke()都要重绘从起点到当前点的所有线段,导致帧率急剧下降。
3.2 正确实现(每段独立绘制)
// ✅ 正确写法.onTouch((event:TouchEvent)=>{consttouch=event.touches[0];switch(event.type){caseTouchType.Down:this.isDrawing=true;this.canvasCtx.beginPath();this.canvasCtx.moveTo(touch.x,touch.y);break;caseTouchType.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;caseTouchType.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(){constw=this.canvasCtx.width;consth=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';constpixelMap:image.PixelMap=awaitcomponentSnapshot.get('speedDictCanvas');6.2 注意事项
- 截图是异步操作,需要
await - 截图分辨率取决于设备像素密度(高分屏可能很大)
- 截图包含 Canvas 的透明背景——但因为 Stack 下面有白底层,最终截图是白底的
七、水印开关
7.1 状态控制
@StateshowWatermark: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,获取识别结果,实现"写完即识别"的核心功能。
