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

本文会落到四个结果:
- 用
StylusPoint和Stroke保存原始笔迹,不只保存最终图片。 - 用采样缓冲降低高频输入对页面刷新的压力。
- 用压感映射笔宽和透明度,让笔锋变化可控。
- 用 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 文档 |
| 保存失败 | 有重试或草稿提示,不会静默丢失 |
| 数据结构 | 保存原始点,而不是只保存一张图片 |
建议至少用真机、平板或支持笔输入的设备完成验收。模拟器可以检查页面逻辑,但不能完全代表真实落笔体验。
十二、手写笔与输入事件相关资料
- 华为开发者文档:Stage 模型应用开发
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/stage-model-development-overview - 华为开发者文档:ArkUI 输入事件
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-interaction-basic-principles - 华为开发者文档:Canvas 绘制能力
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-components-canvas-canvas - 华为开发者文档:应用文件管理
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/app-file-access
十三、把手写笔做成可靠输入能力
手写笔适配的关键是把“落笔”拆成可维护的链路:输入点记录事实,缓冲区控制节奏,显示点负责绘制效果,历史栈管理撤销重做,保存队列保证数据最终落盘。
最后用这张表复盘实现:
| 问题 | 稳定答案 |
|---|---|
| 笔迹记录什么 | StylusPoint 保存坐标、压感、倾斜和时间 |
| 高频输入怎么处理 | StrokeInputBuffer 批量消费 |
| 笔锋怎么自然 | 压感映射到宽度和透明度,并做限幅 |
| 撤销怎么符合直觉 | 以 Stroke 为单位入栈 |
| 切后台怎么不丢 | 生成草稿状态,保存未完成的一笔 |
更多推荐



所有评论(0)