鸿蒙实战:手写板 WritePage 触摸绘图实现
前言(必读)

图:鸿蒙实战:手写板 WritePage 触摸绘图实现(第 79 篇) 运行效果截图(HarmonyOS NEXT)
手写板让用户可以直接用手指在屏幕上书写,无需纸笔。本项目通过 Canvas 2D API + 触摸事件处理,实现了一个完整的手写板:触摸绘图 → 笔画历史管理 → 撤销/清空 → 分段进度驱动。本文从触摸事件体系到手写板实现逐层解析。

图:WritePage 手写板架构------触摸事件处理、Canvas 绘制与笔画历史管理
#mermaid-svg-RMia06aOBYzEcUkF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RMia06aOBYzEcUkF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RMia06aOBYzEcUkF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RMia06aOBYzEcUkF .error-icon{fill:#552222;}#mermaid-svg-RMia06aOBYzEcUkF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RMia06aOBYzEcUkF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RMia06aOBYzEcUkF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RMia06aOBYzEcUkF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RMia06aOBYzEcUkF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RMia06aOBYzEcUkF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RMia06aOBYzEcUkF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RMia06aOBYzEcUkF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RMia06aOBYzEcUkF .marker.cross{stroke:#333333;}#mermaid-svg-RMia06aOBYzEcUkF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RMia06aOBYzEcUkF p{margin:0;}#mermaid-svg-RMia06aOBYzEcUkF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RMia06aOBYzEcUkF .cluster-label text{fill:#333;}#mermaid-svg-RMia06aOBYzEcUkF .cluster-label span{color:#333;}#mermaid-svg-RMia06aOBYzEcUkF .cluster-label span p{background-color:transparent;}#mermaid-svg-RMia06aOBYzEcUkF .label text,#mermaid-svg-RMia06aOBYzEcUkF span{fill:#333;color:#333;}#mermaid-svg-RMia06aOBYzEcUkF .node rect,#mermaid-svg-RMia06aOBYzEcUkF .node circle,#mermaid-svg-RMia06aOBYzEcUkF .node ellipse,#mermaid-svg-RMia06aOBYzEcUkF .node polygon,#mermaid-svg-RMia06aOBYzEcUkF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RMia06aOBYzEcUkF .rough-node .label text,#mermaid-svg-RMia06aOBYzEcUkF .node .label text,#mermaid-svg-RMia06aOBYzEcUkF .image-shape .label,#mermaid-svg-RMia06aOBYzEcUkF .icon-shape .label{text-anchor:middle;}#mermaid-svg-RMia06aOBYzEcUkF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RMia06aOBYzEcUkF .rough-node .label,#mermaid-svg-RMia06aOBYzEcUkF .node .label,#mermaid-svg-RMia06aOBYzEcUkF .image-shape .label,#mermaid-svg-RMia06aOBYzEcUkF .icon-shape .label{text-align:center;}#mermaid-svg-RMia06aOBYzEcUkF .node.clickable{cursor:pointer;}#mermaid-svg-RMia06aOBYzEcUkF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RMia06aOBYzEcUkF .arrowheadPath{fill:#333333;}#mermaid-svg-RMia06aOBYzEcUkF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RMia06aOBYzEcUkF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RMia06aOBYzEcUkF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RMia06aOBYzEcUkF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RMia06aOBYzEcUkF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RMia06aOBYzEcUkF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RMia06aOBYzEcUkF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RMia06aOBYzEcUkF .cluster text{fill:#333;}#mermaid-svg-RMia06aOBYzEcUkF .cluster span{color:#333;}#mermaid-svg-RMia06aOBYzEcUkF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RMia06aOBYzEcUkF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RMia06aOBYzEcUkF rect.text{fill:none;stroke-width:0;}#mermaid-svg-RMia06aOBYzEcUkF .icon-shape,#mermaid-svg-RMia06aOBYzEcUkF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RMia06aOBYzEcUkF .icon-shape p,#mermaid-svg-RMia06aOBYzEcUkF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RMia06aOBYzEcUkF .icon-shape .label rect,#mermaid-svg-RMia06aOBYzEcUkF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RMia06aOBYzEcUkF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RMia06aOBYzEcUkF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RMia06aOBYzEcUkF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} WritePage
触摸事件监听
Canvas 绘图引擎
笔画历史管理
TouchType.Down
beginPath / moveTo
TouchType.Move
lineTo / stroke
TouchType.Up
closePath / 保存笔画
strokeStyle 笔色
lineWidth 笔宽
lineCap round
linJoin round
撤销 undo
restoreImage
清空 clear
clearRect
保存 save
toDataURL→PixelMap
一、触摸事件基础
1.1 TouchEvent API
typescript
.onTouch((e: TouchEvent) => {
// e.type --- 触摸事件类型
// e.touches[0] --- 触摸点数组
// e.touches[0].x --- 触摸点 X 坐标
// e.touches[0].y --- 触摸点 Y 坐标
})
| 事件类型 | 触发时机 | 举例 |
|---|---|---|
TouchType.Down |
手指接触屏幕 | 手指按下 |
TouchType.Move |
手指在屏幕上移动 | 手指拖动 |
TouchType.Up |
手指离开屏幕 | 手指抬起 |
TouchType.Cancel |
触摸被系统中断 | 来电/通知 |
1.2 CanvasRenderingContext2D API
typescript
context: CanvasRenderingContext2D =
new CanvasRenderingContext2D(new RenderingContextSettings(true))
// 核心绘制方法
ctx.beginPath() // 开始新路径
ctx.moveTo(x, y) // 移动到起点
ctx.lineTo(x, y) // 画线到终点
ctx.stroke() // 描边
ctx.clearRect(0, 0, w, h) // 清空画布
// 样式属性
ctx.strokeStyle = '#3D352E' // 描边颜色
ctx.lineWidth = 3 // 线宽
ctx.lineCap = 'round' // 线帽圆角
ctx.lineJoin = 'round' // 连接点圆角
二、WritePage 完整实现
2.1 数据结构
typescript
interface DrawPoint {
x: number
y: number
}
// 笔画历史:DrawPoint[][] --- 每一笔是一个 DrawPoint[] 数组
// history[0] = stroke 1 的所有触摸点
// history[1] = stroke 2 的所有触摸点
// ...
private history: DrawPoint[][] = []
private currentPoints: DrawPoint[] = [] // 当前正在绘制的笔画
private isDrawing: boolean = false // 是否正在绘制
2.2 触摸事件三件套
typescript
// Touch Down:开始记录
private touchStart(e: TouchEvent): void {
this.isDrawing = true
this.currentPoints = [this.touchPoint(e)]
}
// Touch Move:实时绘制
private touchMove(e: TouchEvent): void {
if (!this.isDrawing) return
this.currentPoints.push(this.touchPoint(e))
this.drawCurrentStroke()
}
// Touch Up:完成一笔
private touchEnd(): void {
this.isDrawing = false
if (this.currentPoints.length > 0) {
this.history.push([...this.currentPoints]) // 保存到历史
this.currentPoints = [] // 清空当前
// 自动推进分段进度
if (this.segmentsWritten < this.totalSegments) {
this.segmentsWritten++
}
}
}
private touchPoint(e: TouchEvent): DrawPoint {
return { x: e.touches[0].x, y: e.touches[0].y }
}
2.3 实时绘制与重绘
typescript
// 实时绘制(Touch Move 时调用)
private drawCurrentStroke(): void {
const ctx = this.context
ctx.beginPath()
ctx.moveTo(this.currentPoints[0].x, this.currentPoints[0].y)
for (let i = 1; i < this.currentPoints.length; i++) {
ctx.lineTo(this.currentPoints[i].x, this.currentPoints[i].y)
}
ctx.strokeStyle = '#3D352E'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
}
// 完全重绘(撤销/清空后调用)
private redraw(): void {
const ctx = this.context
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
for (const stroke of this.history) {
ctx.beginPath()
stroke.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
})
ctx.strokeStyle = '#3D352E'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
}
}
三、撤销与清空
typescript
// 撤销上一笔
private undo(): void {
if (this.history.length > 0) {
this.history.pop() // 移除最后一笔
this.redraw() // 完全重绘
if (this.segmentsWritten > 0) this.segmentsWritten-- // 回退分段
}
}
// 清空全部
private clear(): void {
this.history = [] // 清空历史
this.currentPoints = [] // 清空当前
this.segmentsWritten = 0 // 重置分段
const ctx = this.context
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) // 清空画布
}
四、Canvas 尺寸自适应
typescript
Canvas(this.context)
.width('100%')
.height('100%')
.onAreaChange((oldValue: Area, newValue: Area) => {
// 获取 Canvas 实际宽高(用于 clearRect)
this.canvasWidth = newValue.width as number
this.canvasHeight = newValue.height as number
})
.onReady(() => {
// Canvas 就绪后重绘(恢复历史笔画)
this.redraw()
})
| 生命周期事件 | 触发时机 | 用途 |
|---|---|---|
onAreaChange |
组件位置/尺寸变化 | 获取 Canvas 实际宽高 |
onReady |
Canvas 初始化完成 | 首次重绘 |
五、底部工具栏
typescript
// @Builder 封装的可复用按钮
@Builder
ToolBtn(symbol: string, label: string, onTap: () => void) {
Column() {
Text(symbol)
.fontSize(18)
.fontColor(AppColors.TEXT_2)
.width(44).height(44).borderRadius(22)
.backgroundColor(AppColors.CARD)
.shadow({...})
.onClick(onTap)
Text(label)
.fontSize(10)
.fontColor(AppColors.TEXT_3)
.margin({ top: 6 })
}
}
// 调用
Row() {
this.ToolBtn('↩', '撤销', () => this.undo())
Blank().layoutWeight(1)
this.ToolBtn('🗑', '清空', () => this.clear())
Blank().layoutWeight(1)
this.ToolBtn('✎', '粗细', () => {
showToast({ message: '粗细切换开发中', duration: 1000 })
})
}
六、触摸绘图的性能优化
typescript
// 优化 1:Touch Move 不要每帧都重新绘制整张画布
// ❌ 错误:每次 Touch Move 都 clearRect + 全量 redraw
// ✅ 正确:只绘制新增线段
// 优化 2:限制采样频率(可选)
private lastSampleTime: number = 0
private touchMove(e: TouchEvent): void {
if (!this.isDrawing) return
const now = Date.now()
if (now - this.lastSampleTime < 16) return // 60fps 限制
this.lastSampleTime = now
this.currentPoints.push(this.touchPoint(e))
this.drawCurrentStroke()
}
// 优化 3:Canvas 尺寸与设备像素比匹配
// 避免模糊
const scale = 2 // 2x 渲染
canvas.width = this.canvasWidth * scale
canvas.height = this.canvasHeight * scale
ctx.scale(scale, scale)
七、手写数据流向
WritePage 用户书写
↓
@State segmentsWritten = 3 (三段写完)
↓
用户点击「完成」
↓
router.back() 返回首页
↓
捕获 Canvas 为图片 → 与拍照流程合并
↓
pushUrl(AnalyzingPage) → OCR → 特征提取 → 情绪分类
八、WritePage vs 其他绘制方案
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Canvas (本项目) | 手写绘图 | 轻量、触摸事件原生支持 | 笔画端点锯齿需 round |
| SVG | 矢量图形 | 高清放大 | 触摸事件需额外处理 |
| 自定义组件 | 简单标记 | 易实现 | 不适合连续笔画 |
九、注意事项与常见问题
9.1 开发注意事项
在正式开发前,建议按以下步骤完成环境准备与前置检查:
- 版本确认:检查 DevEco Studio 与 SDK 版本,确保满足目标 API Level 要求
- 权限声明 :在
module.json5的requestPermissions字段中提前声明所有需要的系统权限 - 设备能力检查:调用前验证设备是否支持目标能力(相机、NFC、传感器等)
- 异步封装 :所有耗时操作(数据库、文件 I/O、网络请求)统一使用
async/await处理 - 资源释放 :在组件
aboutToDisappear()生命周期钩子中及时释放系统资源,防止内存泄漏
9.2 常见错误与解决方案
常见问题快速排查表:
| 问题类型 | 排查方向 | 参考方法 |
|---|---|---|
| 应用崩溃 | 查看 hilog 错误日志 | hilog.error(TAG, "...", e.message) |
| 状态丢失 | 检查 AppStorage 键名拼写 | 统一使用常量管理键名 |
| 动画不流畅 | 避免在 animateTo 回调中执行 I/O | 动画与数据操作分离 |
总结
- ✅ 触摸事件三件套:Touch Down(开始)/ Move(绘制)/ Up(完成并保存)
- ✅ Canvas 2D 绘制:beginPath → moveTo → lineTo → stroke 标准管线
- ✅ 笔画历史管理 :
DrawPoint[][]存储每一笔的所有触摸点 - ✅ 撤销/清空:pop 历史 + redraw 全量重绘
- ✅ Canvas 尺寸自适应:onAreaChange + onReady 生命周期
- ✅ 性能优化:仅绘制新增线段、60fps 采样限制、像素比适配
- ✅ 数据流向:手写 → Canvas 图片 → 分析流水线
下一篇将深入讲解分段书写引导与进度设计。
📌 收藏提示:触摸绘图的关键是"三件套时刻"------Touch Down 记录起点、Move 实时绘制、Up 保存笔画到历史。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- Canvas CanvasRenderingContext2D
- TouchEvent 触摸事件
- onTouch 手势处理
- animateTo 显式动画
- HarmonyOS NEXT 开发指南
- 第44篇 Canvas 组件基础
- 第61篇 交互反馈动画
- 第50篇 Canvas 动画
七、手写板高级功能实现
7.1 贝塞尔曲线平滑笔画
直接用 lineTo 绘制触摸点会产生折线感,使用贝塞尔曲线可以平滑笔画:
typescript
// 平滑笔画核心算法
private drawSmoothLine(points: TouchPoint[]) {
if (points.length < 2) return;
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
// 取相邻两点的中间点作为控制点
const midX = (points[i].x + points[i + 1].x) / 2;
const midY = (points[i].y + points[i + 1].y) / 2;
ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY);
}
ctx.stroke();
}
7.2 可变压感模拟
根据触摸点的移动速度动态调整线宽,模拟真实书写压感:
typescript
private getLineWidth(speed: number): number {
// 速度快 → 线细,速度慢 → 线粗(模拟压感)
const maxWidth = 8;
const minWidth = 2;
const normalizedSpeed = Math.min(speed / 20, 1);
return maxWidth - normalizedSpeed * (maxWidth - minWidth);
}
// 计算速度
const speed = Math.sqrt(dx * dx + dy * dy);
this.ctx.lineWidth = this.getLineWidth(speed);
7.3 分段书写的进度管理
书写板支持分段书写(每次写一段),通过进度追踪实现:
typescript
@State currentSegment: number = 1; // 当前书写段落
@State totalSegments: number = 4; // 总段落数
@State segmentImages: string[] = []; // 每段保存的图片路径
private async finishSegment() {
// 保存当前段落
const pixelMap = await this.canvasContext.getPixelMap(0, 0, this.width, this.height);
const path = await FileUtil.savePixelMap(pixelMap, `segment_${this.currentSegment}`);
this.segmentImages.push(path);
if (this.currentSegment >= this.totalSegments) {
// 全部完成,跳转分析
router.pushUrl({ url: 'pages/AnalyzingPage', params: { segments: this.segmentImages } });
} else {
// 清空画板,准备下一段
this.ctx.clearRect(0, 0, this.width, this.height);
this.currentSegment++;
}
}
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!