HarmonyOS 7 课堂批注坐标映射与脏区重绘(连载03/06)【共创稿事节】
一块白板上的笔迹越多,“新增一小段线条”越不应该导致整张画布被清空再画一遍。课堂批注的第二篇已经把原始触点转换为线段和二次贝塞尔曲线,线条本身更平顺了;但当教师把画布放大到 150%,再向右拖动时,另一种问题就会出现:手指仍在屏幕上移动,笔迹却偏离笔尖,擦掉一小块内容时还可能把旁边的历史笔迹擦穿。
这并不是一个简单的性能问题。它首先是坐标归属和重绘范围的问题。坐标错了,局部重绘的矩形就会框错地方;局部重绘只清除、不重建与该区域相交的内容,则会留下缺口。本篇把画布分成“文档空间的数据”“视口空间的呈现”和“屏幕输入的映射”,再让脏区裁剪只承担减少重复绘制的工作。

视口变换与局部绘制范围应由相同的坐标关系确定。
先把三个坐标系说清楚
课堂笔记的数据有自己的逻辑宽度,例如以 1000 × 700 为单位的一页;它不随设备更换而变化。触控事件拿到的是当前 Canvas 区域内的触点位置,坐标由绘制控件与其布局确定。在画布缩放或平移后,一个屏幕触点要通过逆变换才能找到对应的文档坐标。最后,Canvas 绘图指令使用的上下文单位需要和本层绘制方案一致,不能一处把 vp 当成 px,另一处又乘设备密度。
| 名称 | 用途 | 会随手势变化吗 | 建议归属 |
|---|---|---|---|
| 文档坐标 | 长期保存的笔迹、曲线控制点 | 否 | 笔迹模型 |
| 视口坐标 | 放大、平移后的可见位置 | 是 | 视口变换对象 |
| Canvas 绘制坐标 | 当前绘制命令使用的位置 | 取决于变换实现 | 渲染器 |
| 脏区矩形 | 本次需要恢复的屏幕区域 | 是 | 帧内更新队列 |
这里选用一个容易检查的约定:画布的所有笔迹长期保留在文档坐标中;渲染器在发出每条绘制命令前,将点转为 Canvas 局部绘制坐标。这样可以避免交替使用 context.scale() 和手工乘缩放系数造成二次变换。若团队选择矩阵变换方案,同样成立,但要确保在 save() 与 restore() 之间管理好矩阵状态。
// model/ViewportTransform.ets
export interface BoardPoint {
x: number;
y: number;
}
export class ViewportTransform {
private scaleValue: number = 1;
private offsetX: number = 0;
private offsetY: number = 0;
get scale(): number { return this.scaleValue; }
toScreen(p: BoardPoint): BoardPoint {
return {
x: p.x * this.scaleValue + this.offsetX,
y: p.y * this.scaleValue + this.offsetY
};
}
toDocument(p: BoardPoint): BoardPoint {
return {
x: (p.x - this.offsetX) / this.scaleValue,
y: (p.y - this.offsetY) / this.scaleValue
};
}
pan(dx: number, dy: number): void {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) { return; }
this.offsetX += dx;
this.offsetY += dy;
}
zoomAt(anchor: BoardPoint, desiredScale: number): void {
if (!Number.isFinite(desiredScale)) { return; }
const stablePoint: BoardPoint = this.toDocument(anchor);
const next: number = Math.max(0.5, Math.min(3, desiredScale));
this.scaleValue = next;
this.offsetX = anchor.x - stablePoint.x * next;
this.offsetY = anchor.y - stablePoint.y * next;
}
}
zoomAt() 先求缩放中心对应的文档点,再在新缩放级别下重新计算偏移量。假设手指按在三角形顶点 A 上,放大前后顶点仍位于同一个屏幕位置,视觉上就不会发生突然跳跃。它解决的是视口稳定性,而非笔迹平滑;两者应该各自留在独立模块。
如果工具栏占据了页面左侧,触点坐标还需要先减去画布容器在页面中的偏移。更推荐将事件直接绑定在 Canvas 上,让 toDocument() 只接收画布局部位置。多个页面、弹窗和全屏模式都按同一输入约定处理,调试量会小得多。
脏区不是“刚刚写过的那个点”
笔宽为 4 的线段从 (180, 200) 画到 (250, 240),需要恢复的范围绝非这两个点形成的零宽路径。圆头、转角、贝塞尔控制点、外层描边以及抗锯齿都可能越过中心线。比较可靠的做法是先在文档空间求出曲线保守包围盒,再转换为视口矩形,并按屏幕笔宽增加安全边距。
保守包围盒不必精确计算二次贝塞尔的极值。把起点、控制点、终点的最小最大坐标都纳入即可;这样面积稍大,但可以保证曲线不会跑出界。若画布笔迹密度很高,可继续使用网格索引或四叉树筛选候选线段,第一版先确保绘制结果正确。
// render/DirtyRect.ets
export interface DirtyRect {
x: number;
y: number;
width: number;
height: number;
}
export function expandedRect(points: { x: number; y: number }[],
padding: number): DirtyRect {
if (points.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
const xs: number[] = points.map(p => p.x);
const ys: number[] = points.map(p => p.y);
const left: number = Math.min(...xs) - padding;
const top: number = Math.min(...ys) - padding;
const right: number = Math.max(...xs) + padding;
const bottom: number = Math.max(...ys) + padding;
return { x: left, y: top, width: right - left,
height: bottom - top };
}
export function unionRect(a: DirtyRect, b: DirtyRect): DirtyRect {
const x: number = Math.min(a.x, b.x);
const y: number = Math.min(a.y, b.y);
const right: number = Math.max(a.x + a.width, b.x + b.width);
const bottom: number = Math.max(a.y + a.height, b.y + b.height);
return { x: x, y: y, width: right - x, height: bottom - y };
}
export function overlaps(a: DirtyRect, b: DirtyRect): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x &&
a.y < b.y + b.height && a.y + a.height > b.y;
}
export class DirtyQueue {
private pending: DirtyRect | undefined;
add(rect: DirtyRect): void {
if (rect.width <= 0 || rect.height <= 0) { return; }
this.pending = this.pending ? unionRect(this.pending, rect) : rect;
}
take(): DirtyRect | undefined {
const result: DirtyRect | undefined = this.pending;
this.pending = undefined;
return result;
}
}
这里的 DirtyQueue 把连续触点累积成一个矩形,避免一个触点就清空一次缓冲区。当笔画跨越很长距离,合并后的矩形可能接近全画布;此时全量重画反而更容易控制开销。可以根据脏区占可见画布的面积比例做切换,例如超过 45% 就改为全量重绘。这个阈值需要结合页面结构和笔迹数量调整,不应当被解释为固定的系统性能指标。
清理区域以后,必须恢复背景与相交笔迹
局部重绘要遵守顺序:保存 Canvas 状态、限制裁剪区域、清除该矩形、补画网格或图片底层、找出与矩形相交的历史曲线、绘制新增笔迹、恢复 Canvas 状态。对背景层的恢复非常关键:如果页面底色是格纸,直接清除会把格线一同擦去,用户看到一个透明方块,就会误以为橡皮工具改变了整页背景。
下面渲染器复用前文的 StrokePiece 数据结构:from、to 是曲线端点,control 是二次贝塞尔控制点,kind 负责区分直线与曲线。所有历史笔迹都存为文档坐标,候选判断和绘制使用同一套视口投影。代码先遍历列表;当笔迹数量增多,只需替换候选查询过程,不必改局部绘制语义。
// render/BoardRenderer.ets
import { StrokePiece } from '../model/StrokeGeometry';
import { ViewportTransform, BoardPoint } from '../model/ViewportTransform';
import { DirtyRect, overlaps, expandedRect } from './DirtyRect';
export class BoardRenderer {
constructor(private view: ViewportTransform) {}
private screenPoints(piece: StrokePiece): BoardPoint[] {
const points: BoardPoint[] = [
this.view.toScreen(piece.from), this.view.toScreen(piece.to)
];
if (piece.control) { points.push(this.view.toScreen(piece.control)); }
return points;
}
redraw(ctx: CanvasRenderingContext2D, dirty: DirtyRect,
pieces: StrokePiece[]): void {
ctx.save();
ctx.beginPath();
ctx.rect(dirty.x, dirty.y, dirty.width, dirty.height);
ctx.clip();
ctx.clearRect(dirty.x, dirty.y, dirty.width, dirty.height);
ctx.fillStyle = '#F8FAFF';
ctx.fillRect(dirty.x, dirty.y, dirty.width, dirty.height);
this.drawGrid(ctx, dirty);
ctx.strokeStyle = '#1769E8';
ctx.lineWidth = 4 * this.view.scale;
ctx.lineCap = 'round';
for (const piece of pieces) {
const pts: BoardPoint[] = this.screenPoints(piece);
const bounds: DirtyRect = expandedRect(pts,
ctx.lineWidth / 2 + 3);
if (!overlaps(bounds, dirty)) { continue; }
const start: BoardPoint = pts[0];
const end: BoardPoint = pts[1];
ctx.beginPath();
ctx.moveTo(start.x, start.y);
if (piece.kind === 'quadratic' && pts.length === 3) {
ctx.quadraticCurveTo(pts[2].x, pts[2].y, end.x, end.y);
} else {
ctx.lineTo(end.x, end.y);
}
ctx.stroke();
}
ctx.restore();
}
private drawGrid(ctx: CanvasRenderingContext2D,
rect: DirtyRect): void {
// 网格以画布局部坐标绘制,并服从当前裁剪区域
ctx.strokeStyle = '#E2EAF5';
ctx.lineWidth = 1;
const step: number = 32;
const right: number = rect.x + rect.width;
const bottom: number = rect.y + rect.height;
for (let x: number = Math.floor(rect.x / step) * step;
x <= right; x += step) {
ctx.beginPath(); ctx.moveTo(x, rect.y);
ctx.lineTo(x, bottom); ctx.stroke();
}
for (let y: number = Math.floor(rect.y / step) * step;
y <= bottom; y += step) {
ctx.beginPath(); ctx.moveTo(rect.x, y);
ctx.lineTo(right, y); ctx.stroke();
}
}
}
这份实现以屏幕固定格线为底色。如果网格属于文档内容,格线也必须经过 ViewportTransform,才能随着笔迹一起平移缩放。两种产品交互都合理,但不应混用。在分层架构中,背景、历史笔迹、正在书写的笔迹、选区高亮最好分为不同的逻辑层,重绘时按层序恢复。
在渲染对象内部,还应明确“谁负责触发一次重绘”。建议由页面统一提交脏区并选择刷新时机,渲染器只接收已经计算好的区域与完整笔迹集合。如果工具栏、手势层和数据层都可以自行调用 clearRect(),同一时刻可能发生两次互相覆盖的清理操作。把绘图入口收敛为一个接口,排查残影时也只需要看这一条调用链。

坐标逆变换和脏区限制分别解决输入与绘制问题。
把多触点采集结果接入脏区管线
前文的 TouchRouter 负责按触点 ID 分流,SmoothStrokeRouter 输出 StrokePiece。现在最重要的变化不是修改这两个类,而是改变它们在画布上的使用方式:收到新的曲线片段,先将片段加入持久的笔迹列表,再计算它的屏幕包围盒并加入脏区队列。只有真正执行重绘时才消费这段队列。
// pages/BoardPainter.ets 中的核心调度类
import { StrokePiece } from '../model/StrokeGeometry';
import { ViewportTransform } from '../model/ViewportTransform';
import { DirtyQueue, expandedRect, DirtyRect } from '../render/DirtyRect';
import { BoardRenderer } from '../render/BoardRenderer';
export class BoardPainter {
private view: ViewportTransform = new ViewportTransform();
private renderer: BoardRenderer = new BoardRenderer(this.view);
private queue: DirtyQueue = new DirtyQueue();
private pieces: StrokePiece[] = [];
addPiece(piece: StrokePiece): void {
this.pieces.push(piece);
const points = [this.view.toScreen(piece.from),
this.view.toScreen(piece.to)];
if (piece.control) { points.push(this.view.toScreen(piece.control)); }
this.queue.add(expandedRect(points, 4 * this.view.scale + 3));
}
drawPending(ctx: CanvasRenderingContext2D): void {
const rect: DirtyRect | undefined = this.queue.take();
if (rect) { this.renderer.redraw(ctx, rect, this.pieces); }
}
zoomAndRepaint(anchorX: number, anchorY: number,
scale: number, canvasW: number, canvasH: number,
ctx: CanvasRenderingContext2D): void {
this.view.zoomAt({ x: anchorX, y: anchorY }, scale);
this.renderer.redraw(ctx,
{ x: 0, y: 0, width: canvasW, height: canvasH }, this.pieces);
}
toDocument(x: number, y: number): { x: number; y: number } {
return this.view.toDocument({ x: x, y: y });
}
}
这里特意让放大操作走全量重绘。因为缩放改变了整页历史笔迹的位置,原来的屏幕脏区已经不再覆盖相同内容,继续只画“最近一小段”会产生影像残留。平移也是如此:不能因为画布本身没有新增笔迹就跳过刷新。局部重绘适合在视口不变时发生的笔迹增量,视口整体变化则需要重建可见内容。
通过可重复的场景检查错误
可以按以下顺序检查:先在 100% 大小下画一条穿过三角形的波浪线,再缩放至 150%,让放大中心落在线条中间;随后平移 80 个视口单位,继续从原曲线末端落笔。若新旧线条在视觉上分离,优先比较触点的 toDocument() 结果,不要先改贝塞尔算法。再擦除曲线中央一小段,观察格线能否恢复,以及被脏区边框截到的其他笔迹是否仍完整。
检查边界还包括:缩放比例接近下限、手指落在画布最右侧、两个触点同时移动、快速拖动导致脏区跨越整个屏幕、页面恢复后视口比例发生变化。遇到高度密集的批注时,可在 HiLog 中记录脏区面积和筛选到的线段数量,用数据决定是增加索引还是调整整页重绘策略。记录值来自运行时采集,不要用预先写定的数字当作性能结论。

画面同时给出屏幕坐标、文档坐标与待更新区域。
还有一个容易被忽略的地方:Canvas 对象和图像数据可能涉及 vp 与 px 的换算。华为开发者文档已经解释过旧画布 API 处理图像数据时的单位差异。这里选用 CanvasRenderingContext2D,并在同一渲染器内处理坐标,不把图片像素尺寸和触控坐标直接相加。图片导出放到后续章节讨论时,仍要显式区分逻辑尺寸和输出分辨率。
至此,笔迹采集、几何平滑、坐标转换与局部重绘已经形成四个可独立检查的模块。下一篇要解决“用户撤销一次笔画,应该撤掉什么”的问题。既然渲染器以历史曲线为输入,撤销与重做就不需要直接操作 Canvas 的像素,而是改变笔迹命令集合,再为受影响区域发出重绘请求。
输入采样为什么必须先经过逆变换
多触点按 ID 分流的逻辑仍然有价值,但它拿到的采样点必须改变含义。此前在画布未缩放时,TouchRouter 直接把触点位置写入 InkPoint;现在应把这些触点先投影回文档空间,再进入 StrokeCollector。这样同一条笔迹在页面缩放前后使用的是同一套持久坐标,不会把第一个点存成视口单位、第二个点存成文档单位。
// model/PointInputAdapter.ets
import { InkPoint } from './StrokeCollector';
import { ViewportTransform } from './ViewportTransform';
export function toInkDocumentPoint(x: number, y: number,
seq: number, view: ViewportTransform): InkPoint {
const point = view.toDocument({ x: x, y: y });
return { x: point.x, y: point.y, seq: seq };
}
将这一步放在触点分流之前,比在绘制完成后统一修改所有笔迹更容易控制。特别是在两根手指同时参与时,两条路径必须共享同一个视口版本;否则先处理的一根手指可能处于旧缩放比例,后处理的一根手指处于新比例。手势操作可以先完成视口更新,再开始下一组笔迹采样,避免两个职责争夺一次触摸序列。
更多推荐

所有评论(0)