HarmonyOS 7 课堂批注笔迹平滑与曲线几何建模(连载02/06)【共创稿事节】
学生在批注板上画一道弧线,肉眼看到的是连续动作;事件系统交给应用的,却是一串离散坐标。直接将相邻点用 lineTo() 相连时,低速书写看起来还算自然,遇到采样间距变化、手指轻微颤动或急转弯,线条就容易出现锯齿和尖刺。把点数直接减半虽然能减少绘制命令,却可能抹掉几何图形最重要的转折位置。
上一篇已经把 TouchEvent.changedTouches 按触点 ID 分流,StrokeCollector 保存每根手指的原始点序列,TouchRouter 输出 InkSegment。本篇不更改这条输入链,而是在触点采集和 Canvas 绘制之间插入几何层:原始点继续保留,几何层过滤微小重复位移,将相邻点转成二次贝塞尔片段,并对明显转角采取不同处理。这样日后做撤销、重绘和图片导出时,既能保留可追溯的输入数据,也能复用稳定的视觉路径。

平滑算法先回答三个边界问题
第一个是起笔。二次贝塞尔曲线需要起点、控制点和终点,只有一个采样点时不能凑出一条弧线。因此首点应保留为活动轨迹的起点,第二点只建立局部趋势,收到第三点之后再输出第一段曲线。第二个是收笔。如果每次都把当前点与下一点的中点作为终点,整条笔迹会永远停在倒数两点之间;抬笔时必须补上一段到最终原始点的尾段。第三个是拐角。画一个直角、勾选符号或折线箭头时,统一用平滑曲线连接控制点会把尖角削成圆弧。
因此需要的不是笼统的“平滑开关”,而是一个有状态的路径构建器。它收到点后返回零个或多个绘制片段;收到结束指令后输出尾段。几何片段存储真实的 from、to 和可选 control,不能只存屏幕上的像素结果。
我们先约定一条曲线的生成方法:假设连续三点为 A、B、C,以 B 为控制点、以 B 和 C 的中点为终点,曲线从 A 出发。下一次收到 D,则从 B/C 中点出发,以 C 为控制点,延伸到 C/D 中点。每次只向前推进一小段,控制点始终来自原始点;这使增量绘制与后续全量重建可以采用同一组几何片段。
先定义跨层共享的几何对象
几何层不要依赖 ArkUI 组件实例,也不要在类内部调用 CanvasRenderingContext2D。组件只负责将片段转成绘图指令;几何算法可以用单独的输入序列检查连续性、角度阈值和终点覆盖。
// model/StrokeGeometry.ets
import { InkPoint } from './StrokeCollector';
export type PieceKind = 'line' | 'quadratic';
export interface StrokePiece {
kind: PieceKind;
from: InkPoint;
to: InkPoint;
control?: InkPoint;
}
export function midpoint(a: InkPoint, b: InkPoint): InkPoint {
return {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
seq: b.seq
};
}
export function squaredDistance(a: InkPoint, b: InkPoint): number {
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
return dx * dx + dy * dy;
}
export function hasSharpCorner(
prev: InkPoint, current: InkPoint, next: InkPoint
): boolean {
const ax: number = current.x - prev.x;
const ay: number = current.y - prev.y;
const bx: number = next.x - current.x;
const by: number = next.y - current.y;
const aa: number = ax * ax + ay * ay;
const bb: number = bx * bx + by * by;
if (aa < 0.01 || bb < 0.01) { return false; }
const cosine: number = (ax * bx + ay * by) / Math.sqrt(aa * bb);
return cosine < 0.57;
}
hasSharpCorner() 比较的是行进方向的夹角,不是三个点形成的内角。方向连续时余弦接近 1;方向突然变化时数值下降。这里使用 0.57 作为应用层的拐角阈值,便于为折线批注保留尖角,并非设备提供的固定参数。若输入数据来自书写笔而不是手指,可以根据压力、速度和工具类型再调整,但不应先假设所有设备有相同采样频率。
使用中点路径,不在每个事件上重算整条线
一种代价较高的写法,是每收到一个新点就清空画布,再让全部历史点执行一次平滑。轨迹只有几十点时不明显,页面还包含底图、图片、文字标注后,重复重绘便会放大开销。更适合当前阶段的是让路径构建器增量产出 StrokePiece,一条新片段对应一组 quadraticCurveTo() 或 lineTo() 调用。
上一节的 StrokeCollector.move() 已过滤极密的重复点,但阈值仅为约 0.4vp。几何层可以采用另一个更保守的距离阈值,例如平方距离小于 1 的坐标不参与平滑。在绘制时仍保留原始点列表,从而避免过滤逻辑与输入采集逻辑互相覆盖。
// model/MidpointCurveBuilder.ets
import { InkPoint } from './StrokeCollector';
import { StrokePiece, midpoint, squaredDistance,
hasSharpCorner } from './StrokeGeometry';
export class MidpointCurveBuilder {
private points: InkPoint[] = [];
append(point: InkPoint): StrokePiece[] {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
return [];
}
const oldSize: number = this.points.length;
if (oldSize > 0 &&
squaredDistance(this.points[oldSize - 1], point) < 1) {
return [];
}
this.points.push(point);
const n: number = this.points.length;
if (n < 3) { return []; }
const prev: InkPoint = this.points[n - 3];
const current: InkPoint = this.points[n - 2];
const next: InkPoint = this.points[n - 1];
const from: InkPoint = n === 3 ? prev : midpoint(prev, current);
const to: InkPoint = midpoint(current, next);
if (hasSharpCorner(prev, current, next)) {
return [
{ kind: 'line', from: from, to: current },
{ kind: 'line', from: current, to: to }
];
}
return [{
kind: 'quadratic', from: from,
control: current, to: to
}];
}
finish(): StrokePiece[] {
const n: number = this.points.length;
if (n === 0) { return []; }
if (n === 1) {
const p: InkPoint = this.points[0];
return [{ kind: 'line', from: p, to: p }];
}
if (n === 2) {
return [{ kind: 'line', from: this.points[0], to: this.points[1] }];
}
const last: InkPoint = this.points[n - 1];
const before: InkPoint = this.points[n - 2];
return [{
kind: 'quadratic', from: midpoint(before, last),
control: last, to: last
}];
}
}
这个实现专门覆盖三类情况:两点构成直线;三点或更多点构成二次曲线;单点收尾交给绘制层画一个圆端小点。seq 沿用前一篇的数据结构,记录的仍是采样顺序,而每个平滑片段的端点可能来自相邻采样点的中点。对同一条轨迹执行 append()、finish() 的顺序必须固定,否则尾段会被重复追加。
有一个容易误判的问题:画布上某个拐角已经出现,继续滑动手指后却看起来变圆了。通常不是画布抗锯齿,而是构建器在新的三个点到达时重新选择了控制点。这套增量算法只追加片段,不改写已绘制路径,因此路径轮廓不会随着后续采样反复漂移。代价是它依赖最近三个点,对于极端抖动不能替代完整的轨迹拟合;复杂几何识别应独立于这一层。

把上一节的 InkSegment 接进来
TouchRouter.route() 返回按手指分流的 InkSegment,每一段包含 from 和 to。如果在新的几何层中忽略 pointerId,刚刚修好的多指隔离就会失效。因此平滑器应当为每根活动手指建立独立的 MidpointCurveBuilder,在 Up 时补尾段,在 Cancel 时清除未完成的构建器。
还需注意:一个 Move 事件可能被采集层过滤掉,没有新线段时平滑层无需做任何事;收笔事件如果没有位移线段,仍必须完成尾部收口。这决定了 append() 与 finish() 是两个不同接口。
// model/SmoothStrokeRouter.ets
import { InkSegment } from './StrokeCollector';
import { StrokePiece } from './StrokeGeometry';
import { MidpointCurveBuilder } from './MidpointCurveBuilder';
export class SmoothStrokeRouter {
private active: Map<number, MidpointCurveBuilder> =
new Map<number, MidpointCurveBuilder>();
consume(segment: InkSegment): StrokePiece[] {
let builder: MidpointCurveBuilder | undefined =
this.active.get(segment.pointerId);
if (!builder) {
builder = new MidpointCurveBuilder();
builder.append(segment.from);
this.active.set(segment.pointerId, builder);
}
return builder.append(segment.to);
}
finish(pointerId: number): StrokePiece[] {
const builder: MidpointCurveBuilder | undefined = this.active.get(pointerId);
this.active.delete(pointerId);
return builder ? builder.finish() : [];
}
cancel(pointerId: number): void {
this.active.delete(pointerId);
}
}
此处原始点依然保存在 StrokeCollector 中。为什么不直接保存 StrokePiece[]?因为中点算法未来可能替换为 Catmull-Rom 或基于速度的局部拟合;如果历史记录只剩二次曲线,重新调整平滑参数会很困难。将原始采样与几何结果分开保存,既便于重新绘制,也为后续撤销、恢复提供清晰的数据来源。
Canvas 绘制时,线宽与单位要一并确定
HarmonyOS ArkUI 的 Canvas 与 CanvasRenderingContext2D 提供 moveTo()、lineTo()、quadraticCurveTo() 等绘制指令。这里在画布准备好后设置 lineCap、lineJoin 和线宽。输入点来自组件局部坐标,建模和绘制沿用同一坐标系,避免在几何处理时把 windowX 和 x 混在一起。
绘制层只关心片段类型,不再读取触摸事件。对单点笔迹,向相邻的极小偏移坐标画线,可配合圆形端帽显示一个小圆点。下面给出页面级接线方式:先执行原有采集路由,再送入平滑路由,最后把新增片段绘出。Up/Cancel 仍由触摸事件类型决定,顺序不能颠倒。
// pages/InkBoard.ets
import { StrokeCollector, InkSegment, InkStroke, InkPoint } from '../model/StrokeCollector';
import { TouchRouter } from '../model/TouchRouter';
import { StrokePiece } from '../model/StrokeGeometry';
import { SmoothStrokeRouter } from '../model/SmoothStrokeRouter';
@Entry
@Component
struct InkBoard {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings);
private collector: StrokeCollector = new StrokeCollector();
private touchRouter: TouchRouter = new TouchRouter(this.collector);
private smoothing: SmoothStrokeRouter = new SmoothStrokeRouter();
@State private status: string = '手写轨迹等待中';
private renderPiece(piece: StrokePiece): void {
this.context.beginPath();
this.context.moveTo(piece.from.x, piece.from.y);
if (piece.kind === 'quadratic' && piece.control) {
this.context.quadraticCurveTo(
piece.control.x, piece.control.y, piece.to.x, piece.to.y);
} else if (piece.from.x === piece.to.x && piece.from.y === piece.to.y) {
this.context.lineTo(piece.to.x + 0.01, piece.to.y);
} else {
this.context.lineTo(piece.to.x, piece.to.y);
}
this.context.stroke();
}
private handleTouch(event: TouchEvent): void {
const segments: InkSegment[] = this.touchRouter.route(event);
for (const segment of segments) {
const pieces: StrokePiece[] = this.smoothing.consume(segment);
for (const piece of pieces) { this.renderPiece(piece); }
}
if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
for (const t of event.changedTouches) {
if (event.type === TouchType.Up) {
const tail: StrokePiece[] = this.smoothing.finish(t.id);
for (const piece of tail) { this.renderPiece(piece); }
if (tail.length === 0) {
const history: InkStroke[] = this.collector.allCompleted();
for (let i: number = history.length - 1; i >= 0; i--) {
if (history[i].pointerId === t.id) {
if (history[i].points.length === 1) {
const dot: InkPoint = history[i].points[0];
this.renderPiece({ kind: 'line', from: dot, to: dot });
}
break;
}
}
}
} else {
this.smoothing.cancel(t.id);
}
}
}
this.status = `活动触点:${this.collector.activeCount()}`;
}
build() {
Column({ space: 12 }) {
Text('课堂协作批注板').fontSize(23)
Text(this.status).fontSize(14)
Canvas(this.context)
.width('100%').height(520)
.backgroundColor('#F8FAFF')
.onReady(() => {
this.context.strokeStyle = '#1769E8';
this.context.lineWidth = 4;
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
})
.onTouch((event: TouchEvent) => { this.handleTouch(event); })
}.padding(16).width('100%')
}
}

处理速度变化时,不要只看点距
笔迹的采样间隔并不恒定。手指快速滑动时,相邻点可能跨过较长距离;停顿时,同一片区域又可能积累很多点。仅按固定距离删除所有相近点,容易让低速的细小环形笔迹失去形状。更成熟的做法是让采集层保留 seq 和后续可扩展的时间信息,在平滑层配置不同工具的过滤参数,并将阈值写成独立策略,而不是散落在事件回调中的魔法数字。
对于一次明显的快速跳跃,还应判断是否超出画布尺寸的合理范围。这个判断不能粗暴地把长线段认定为错误:使用者确实可能在一瞬间画出长直线。可以先将异常候选记录为需要观察的几何事件,再结合相邻两段的方向、窗口尺寸变化以及触点 ID 是否延续来判定。该策略的落脚点是保护真实笔迹,而不是为减少点数牺牲绘图语义。
对高频书写来说,曲线增量追加是一项重要优化,但它只能减少当前笔迹的局部工作量。待页面加入背景图片、网格、其他学生的既有笔迹后,任何清屏重绘都应依赖可复用的几何列表;不能指望 Canvas 自动找回已经被擦掉的线。后续的局部重绘章节将把这一约束落到具体的坐标转换和脏矩形计算中。
几何结果的检查,重点不只是“看着顺眼”
建议准备几组手写输入:一条水平慢速直线、一条大幅 S 形曲线、一个尖锐的 V 形、只有一个点的轻触,以及连续两根手指同时画线。每组输入都可以记录采样点数、输出片段数、拐角判断次数和最终终点误差。检查方法是对比最后一段的 to 与最后一个原始点的位置,要求收笔落点保持一致;如果末端停在中点上,就说明 finish() 丢失了尾段。
对于拐角被过度圆滑的情况,应查看 hasSharpCorner() 是否把方向突变识别出来。对于线条断开,应检查 StrokePiece.from 是否等于前一段的 to;如果不连续,优先修正几何生成逻辑,而不是用更粗的笔刷遮挡缺口。对于短线条完全消失,则需要检查两层距离过滤的阈值是否叠加过强。
性能方面,增量绘制能减少重复画整块画布的次数,但它也留下一个后续需要处理的缺点:活动笔迹被取消后,已经画出的片段仍可能停留在像素层。下一篇将加入统一的文档坐标、局部重绘区域和有效笔迹列表,使撤销、取消及视口变化都通过可重建的数据源驱动画面,而不依赖残留像素。
手写应用的几何核心不在于追求一条最圆润的曲线,而在于让输入点、视觉路径和最终文档坐标之间具有明确对应关系。保留原始点、按触点管理构建器、为起笔和收笔单独处理边界,是后续扩展到平板和折叠屏批注时更可靠的基础。
更多推荐

所有评论(0)