一块白板上的笔迹越多,“新增一小段线条”越不应该导致整张画布被清空再画一遍。课堂批注的第二篇已经把原始触点转换为线段和二次贝塞尔曲线,线条本身更平顺了;但当教师把画布放大到 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 };
}

将这一步放在触点分流之前,比在绘制完成后统一修改所有笔迹更容易控制。特别是在两根手指同时参与时,两条路径必须共享同一个视口版本;否则先处理的一根手指可能处于旧缩放比例,后处理的一根手指处于新比例。手势操作可以先完成视口更新,再开始下一组笔迹采样,避免两个职责争夺一次触摸序列。

技术参考:HarmonyOS Canvas 组件、画布操作及状态处理、Canvas 像素单位相关问题。

Logo

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

更多推荐