HarmonyOS 手写笔适配实战:压感轨迹、笔锋渲染、撤销重做和低延迟保存

手写笔体验好不好,用户通常几秒钟就能判断出来:落笔有没有延迟,快速划线会不会断,压感有没有层次,撤销是不是按一整笔撤回,切后台再回来笔迹还在不在。
很多绘图、批注、笔记类应用的问题并不在“能不能收到触摸事件”,而在于输入采样、轨迹建模、显示缓冲、历史栈和保存策略没有一起设计。本文围绕一个完整目标展开:在 HarmonyOS 应用里搭一条可维护的手写笔绘制链路,让输入顺、撤销准、保存稳。

请添加图片描述

本文会落到四个结果:

  1. StylusPointStroke 保存原始笔迹,不只保存最终图片。
  2. 用采样缓冲降低高频输入对页面刷新的压力。
  3. 用压感映射笔宽和透明度,让笔锋变化可控。
  4. 用 stroke 级撤销、重做和保存队列,避免切后台或崩溃后丢数据。

一、手写笔体验先看三件事:延迟、断线、撤销

手写笔适配不是 UI 美化问题,而是输入系统问题。读者可以先拿一支笔做三组测试:

测试动作 观察点 暴露的问题
快速画长线 是否断线、锯齿明显 采样丢失或刷新节奏不稳
轻重交替写字 笔画粗细是否自然 压感没有映射或映射过猛
连续写三笔后撤销 是否按整笔撤回 历史栈粒度不正确
切后台再回来 草稿是否还在 保存队列或生命周期处理缺失
放大画布继续写 坐标是否偏移 画布坐标转换错误

请添加图片描述

只要这些基础体验没过关,再多工具栏都救不了。本文会先把笔迹模型和输入链路写稳,再谈渲染与保存。

二、资料与版本边界:本文写绘制链路而非算法库

本文示例面向 HarmonyOS NEXT / Stage 模型 / ArkTS 工程,聚焦手写笔在应用层的工程链路:输入点建模、轨迹缓冲、平滑处理、压感笔锋、撤销重做、落盘保存和异常恢复。具体事件对象、Canvas 能力、设备压感范围和笔倾斜字段,需要以当前 HarmonyOS SDK、设备型号和官方输入事件文档为准。

层级 本文处理 读者需要按项目确认
输入层 笔迹点字段、采样缓冲、结束一笔 实际事件字段、笔设备能力
渲染层 显示点、笔宽、透明度、增量刷新 Canvas 或自研渲染组件
历史层 stroke 级撤销、重做、清空 文档结构和协同编辑规则
保存层 保存队列、失败重试、切后台保存 文件系统、数据库或云同步
验收层 延迟、断线、撤销、恢复测试 真实设备和不同笔型号

请添加图片描述

三、笔迹点模型:位置、压感、倾斜和时间都要记录

如果只保存最终图片,后面无法重放、撤销、平滑或重新渲染。更稳的做法是保存原始点,再根据显示策略生成渲染点。

export interface StylusPoint {
  x: number;
  y: number;
  pressure: number;
  tiltX: number;
  tiltY: number;
  timestamp: number;
}

export interface Stroke {
  strokeId: string;
  color: string;
  baseWidth: number;
  points: StylusPoint[];
  finished: boolean;
}

export function createStroke(strokeId: string, color: string, baseWidth: number): Stroke {
  return {
    strokeId,
    color,
    baseWidth,
    points: [],
    finished: false
  };
}

这段模型的边界是“记录笔迹事实”。它不关心页面怎么画,只保留坐标、压感、倾斜和时间。这样做能避免一个常见失败:用户切换笔刷后,历史笔迹因为只保存图片而无法重新计算显示效果。

四、采样缓冲:高频输入不要直接刷新整页

手写笔输入频率高,如果每个点都触发整页状态更新,页面会卡,笔迹会跟手差。可以先把点放进缓冲区,再由绘制层批量消费。

export class StrokeInputBuffer {
  private pendingPoints: StylusPoint[] = [];

  push(point: StylusPoint): void {
    this.pendingPoints.push(point);
  }

  drain(): StylusPoint[] {
    const points = this.pendingPoints;
    this.pendingPoints = [];
    return points;
  }

  hasPending(): boolean {
    return this.pendingPoints.length > 0;
  }
}

缓冲区只负责收集输入点,不负责绘制,也不负责保存。页面可以在动画帧或固定节奏里调用 drain,把一批点交给轨迹处理函数。这样能降低 UI 状态更新频率,让快速书写更稳定。

五、轨迹平滑:保留原始点,也生成显示点

平滑不能覆盖原始数据。原始点用于恢复和重新计算,显示点用于当前画布绘制。下面示例用简单中点方式降低锯齿,真实项目可以替换成更复杂的曲线算法。

export function buildDisplayPoints(points: StylusPoint[]): StylusPoint[] {
  if (points.length <= 2) {
    return points;
  }

  const displayPoints: StylusPoint[] = [points[0]];
  for (let index = 1; index < points.length - 1; index += 1) {
    const previous = points[index - 1];
    const current = points[index];
    const next = points[index + 1];
    displayPoints.push({
      x: (previous.x + current.x + next.x) / 3,
      y: (previous.y + current.y + next.y) / 3,
      pressure: current.pressure,
      tiltX: current.tiltX,
      tiltY: current.tiltY,
      timestamp: current.timestamp
    });
  }
  displayPoints.push(points[points.length - 1]);
  return displayPoints;
}

这段代码的输入是同一笔的原始点,输出是用于显示的点。它预防的是快速书写时线条毛刺过多,但不会破坏原始采样。后续如果要导出高清图,也可以基于原始点重新生成更高质量的曲线。

六、笔锋策略:压感映射到宽度和透明度

压感不是越真实越好。不同设备的压感范围、用户书写力度和笔刷风格都不同,建议先做可控映射,避免轻轻一写太细、用力一写突然变粗。

export interface BrushStyle {
  width: number;
  alpha: number;
}

export function resolveBrushStyle(baseWidth: number, pressure: number): BrushStyle {
  let normalizedPressure = pressure;
  if (normalizedPressure < 0.15) {
    normalizedPressure = 0.15;
  }
  if (normalizedPressure > 1) {
    normalizedPressure = 1;
  }

  return {
    width: baseWidth * (0.55 + normalizedPressure * 0.75),
    alpha: 0.55 + normalizedPressure * 0.4
  };
}

这段策略把压感限制在合理区间,再映射到笔宽和透明度。它的作用不是追求“物理真实”,而是让用户感觉可控。笔记类应用通常需要稳定清晰,绘画类应用可以把映射曲线做得更有表现力。

七、撤销重做:以 stroke 为单位,不要以点为单位

撤销如果按点执行,用户按一次只退一点点,会非常反直觉。更自然的做法是按一整笔 Stroke 入栈。

export class StrokeHistory {
  private done: Stroke[] = [];
  private undone: Stroke[] = [];

  commit(stroke: Stroke): void {
    if (stroke.points.length === 0) {
      return;
    }
    this.done.push({ ...stroke, finished: true });
    this.undone = [];
  }

  undo(): Stroke | undefined {
    const stroke = this.done.pop();
    if (stroke !== undefined) {
      this.undone.push(stroke);
    }
    return stroke;
  }

  redo(): Stroke | undefined {
    const stroke = this.undone.pop();
    if (stroke !== undefined) {
      this.done.push(stroke);
    }
    return stroke;
  }

  snapshot(): Stroke[] {
    return this.done.map(stroke => ({ ...stroke, points: [...stroke.points] }));
  }
}

历史栈的边界是“管理已经完成的笔画”。它不接收正在书写中的半成品,也不直接保存文件。这样可以保证撤销、重做、保存三个动作看到的是同一份稳定历史。

八、低延迟保存:前台画得快,后台慢慢落盘

写字时不要每个点都落盘。更稳的策略是前台先绘制,完成一笔后把 Stroke 放入保存队列,队列按节奏写入本地草稿或数据库。

export interface StrokeSaveTask {
  documentId: string;
  stroke: Stroke;
  retryCount: number;
}

export class StrokeSaveQueue {
  private tasks: StrokeSaveTask[] = [];
  private saving = false;

  enqueue(task: StrokeSaveTask): void {
    this.tasks.push(task);
  }

  takeNext(): StrokeSaveTask | undefined {
    if (this.saving || this.tasks.length === 0) {
      return undefined;
    }
    this.saving = true;
    return this.tasks.shift();
  }

  finishCurrent(): void {
    this.saving = false;
  }

  hasPending(): boolean {
    return this.tasks.length > 0 || this.saving;
  }
}

保存队列让“绘制体验”和“落盘可靠性”解耦。用户写字时只感知前台绘制;保存失败时可以重试或提示,不会阻塞每一次落笔。

九、异常恢复:应用切后台后不要丢笔迹

切后台、锁屏、窗口销毁时,要处理当前未完成的一笔。最稳的方式是把当前 stroke 标记为临时草稿,并在恢复时询问是否继续。

export interface StylusDraftState {
  documentId: string;
  activeStroke?: Stroke;
  savedStrokeCount: number;
  updatedAt: number;
}

export function buildDraftBeforeBackground(
  documentId: string,
  activeStroke: Stroke | undefined,
  savedStrokeCount: number
): StylusDraftState {
  return {
    documentId,
    activeStroke,
    savedStrokeCount,
    updatedAt: Date.now()
  };
}

export function draftHasUnsavedStroke(draft: StylusDraftState): boolean {
  if (draft.activeStroke === undefined) {
    return false;
  }
  return draft.activeStroke.points.length > 0;
}

这里没有把后台保存写死在生命周期回调里,而是先生成可保存的草稿状态。这样无论项目使用文件、数据库还是云端同步,都可以接入同一份恢复数据。

十、手写笔问题排查表

现象 优先怀疑 检查方式 修复方向
快速书写断线 输入点丢失或 UI 刷新过慢 统计每秒采样点数量和 drain 频率 使用缓冲区批量消费输入点
笔画锯齿严重 直接连接原始点 对比原始点和显示点 增加显示点平滑,保留原始点
压感忽粗忽细 压感没有限幅 打印 pressure 最大值和最小值 resolveBrushStyle 做区间限制
撤销一次只退一点 历史栈按点保存 检查撤销栈元素类型 Stroke 入栈
切后台后丢一笔 当前 stroke 没进入草稿 检查后台前 activeStroke 生成 StylusDraftState 并落盘
保存时书写卡顿 每个点都同步保存 观察落盘频率 完成一笔后进入保存队列

排查时先看输入点是否完整,再看显示点是否合理,最后看保存与恢复。不要一开始就调笔刷颜色,因为视觉问题背后经常是输入链路不稳。

十一、上线前绘制体验验收表

验收项 通过标准
快速书写 长线不断裂,曲线没有明显卡顿
压感表现 轻重书写有层次,但不会突变
撤销重做 每次按整笔撤销或恢复
切后台恢复 未保存笔迹可以恢复或明确提示
多文档切换 A 文档笔迹不会写入 B 文档
保存失败 有重试或草稿提示,不会静默丢失
数据结构 保存原始点,而不是只保存一张图片

建议至少用真机、平板或支持笔输入的设备完成验收。模拟器可以检查页面逻辑,但不能完全代表真实落笔体验。

十二、手写笔与输入事件相关资料

  1. 华为开发者文档:Stage 模型应用开发
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/stage-model-development-overview
  2. 华为开发者文档:ArkUI 输入事件
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-interaction-basic-principles
  3. 华为开发者文档:Canvas 绘制能力
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-components-canvas-canvas
  4. 华为开发者文档:应用文件管理
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/app-file-access

十三、把手写笔做成可靠输入能力

手写笔适配的关键是把“落笔”拆成可维护的链路:输入点记录事实,缓冲区控制节奏,显示点负责绘制效果,历史栈管理撤销重做,保存队列保证数据最终落盘。
最后用这张表复盘实现:

问题 稳定答案
笔迹记录什么 StylusPoint 保存坐标、压感、倾斜和时间
高频输入怎么处理 StrokeInputBuffer 批量消费
笔锋怎么自然 压感映射到宽度和透明度,并做限幅
撤销怎么符合直觉 Stroke 为单位入栈
切后台怎么不丢 生成草稿状态,保存未完成的一笔
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐