鸿蒙实战:手写板 WritePage 触摸绘图实现

鸿蒙实战:手写板 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 开发注意事项

在正式开发前,建议按以下步骤完成环境准备与前置检查:

  1. 版本确认:检查 DevEco Studio 与 SDK 版本,确保满足目标 API Level 要求
  2. 权限声明 :在 module.json5requestPermissions 字段中提前声明所有需要的系统权限
  3. 设备能力检查:调用前验证设备是否支持目标能力(相机、NFC、传感器等)
  4. 异步封装 :所有耗时操作(数据库、文件 I/O、网络请求)统一使用 async/await 处理
  5. 资源释放 :在组件 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 保存笔画到历史。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:


七、手写板高级功能实现

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++;
  }
}

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

相关推荐
在书中成长1 小时前
HarmonyOS 小游戏《对战五子棋》开发第35篇 - 列表渲染性能优化
harmonyos
xianjixiance_2 小时前
鸿蒙实战:SharePage 报告分享——雷达图预览与 pasteboard 剪贴板
华为·harmonyos
全堆鸿蒙12 小时前
27 RdbPredicates 条件查询详解:EqualTo、OrderBy、组合条件
华为·harmonyos·鸿蒙系统
心中有国也有家12 小时前
AtomGit Flutter 鸿蒙客户端: ChangeNotifier 模式
学习·flutter·华为·harmonyos
国服第二切图仔13 小时前
HarmonyOS APP《画伴梦工厂》开发第59篇-碰一碰精准分享——跨设备素材插入
华为·harmonyos
hqzing13 小时前
在鸿蒙 PC 上使用 Claude Code(最新的 Bun 版本)
华为·harmonyos
xd18557855514 小时前
鸿蒙AI菜谱生成器:基于ArkTS原生开发与鸿蒙PC适配实战
人工智能·华为·harmonyos·鸿蒙
GitCode官方15 小时前
开源鸿蒙跨平台直播|QRN 跨端鸿蒙一体化实战
人工智能·华为·开源·harmonyos·atomgit
ZZZMMM.zip16 小时前
演示架构师-PPT大纲生成的HarmonyOS开发实践
人工智能·华为·powerpoint·harmonyos·鸿蒙·鸿蒙系统