从零实现并扩展可自由绘制的画板

326次阅读  |  发布于3年以前

前言

作为一个跑在教室大屏幕上的系统,免不了会与画板打交道。实现一个优秀的画板,可以很好地为在线教学场景提供帮助。我们今天就从 0 开始,实现一个可以自由绘制的 Canvas 画板。

目标有以下几点:

好吧,话不多说,我们开始实现。

devicePixelRatio

dpr (device pixel ratio) 应该是逢 canvas 必涉及的问题了,画布绘制出来的东西模糊?那很可能就是 dpr 没有正确处理。关于 dpr 的详细解释,可以参考这里[1](当然,还可能是绘制文字时没有设置抗锯齿、绘制图片时没有设置平滑)。

window.devicePixelRatio === 4
处理 dpr,笔迹非常清晰 未处理 dpr,笔迹较为模糊

因此我们需要:

  1. 获得它 window.devicePixelRatio
  2. 监听并响应它的变化 (没错,它是会变的。当浏览器从一块屏幕移动到另一块屏幕、使用 Command + + 缩放等都可能导致 dpr 发生变化)
  3. 根据它的变化修改画布的宽度和高度
  4. 缩放 canvas 的 context

监听与响应变化

// 在 react 18 之后,订阅此类外部事件可以使用 useSyncExternalStore

export function useDevicePixelRatio() {
  const [dpr, setDpr] = useState(window.devicePixelRatio);
  useEffect(() => {
    const list = matchMedia(`(resolution: ${dpr}dppx)`);
    const update = () => setDpr(window.devicePixelRatio);
    list.addEventListener('change', update);
    return () => list.removeEventListener('change', update);
  }, [dpr]);
  return { dpr };
}
缩放 context 
// useEffect [dpr] const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); // scale 前先恢复变换矩阵,不然会重复 scale ctx.scale(dpr, dpr); 
<canvas
  width={dimension.width * dpr}
  height={dimension.height * dpr}
  style={{
     width: dimension.width,
     height: dimension.height,
  }}
/>

缩放 context

// useEffect [dpr]
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0); // scale 前先恢复变换矩阵,不然会重复 scale
ctx.scale(dpr, dpr);

响应布局变化

HTML 5 canvas 需要指定宽度、高度才能工作,在实际使用场景中,容器宽高通常是不定的,因此,我们需要对画布进行布局。

  1. 一个外层响应式容器,用于探测父容器及内容的宽度和高度,使用 ResizeObserver 监视它的宽高变化,并更新画布元素的宽高。
  2. 一个容器来存放其他内容,比如在线教学时,老师可能会在学生作答上绘制,因此,画板是与其他元素叠加显示的。

最终,我们将 DOM 元素布局为

<div className="container"> <!-- 1. 这个容器用来响应宽度高度变化 -->
    <div className="canvas"> <!-- 3. 使 canvas 脱离文档流,且不影响布局 -->
        <canvas width={dimension.width} height={dimension.height}> 
    </div>
    <div className="content"> <!-- 2. 这个容器用来放内容 -->
        {children}
    </div>
</div>
/* 3. canvas 需有能力脱离文档流 */
.container {
    position: relative;
}

.canvas {
    touch-action: none; /* 禁用浏览器默认的触控响应,以更好支持多指绘制 */
    user-select: none;
    position: absolute;
    width: 0;
    height: 0;
    left: 0;
    top: 0;
}

/* 4. content 需在 canvas 之上,且不能阻挡 canvas 绘制 */
.content {
    position: relative;
    pointer-events: none;
}
import { addListener, removeListener } from 'resize-detector';
import { debounce } from 'lodash-es';

export type CanvasDimension = {
  width: number;
  height: number;
};

export function useDimensionDetector(ref: MutableRefObject<HTMLDivElement>) {
  const [dimension, setDimension] = useState<CanvasDimension>({
    width: 1,
    height: 1,
  });
  useEffect(() => {
    const { current } = ref;
    const updateDimension = debounce(() => {
      setDimension({
        width: current.clientWidth,
        height: current.clientHeight,
      });
    }, 100);
    updateDimension();
    addListener(current, updateDimension);
    return () => {
      updateDimension.cancel();
      removeListener(current, updateDimension);
    };
  }, [ref]);

  return { dimension };
}

此时,我们已经有了一个可以响应大小变化的 canvas。

绘制准备与初步绘制

取得绘制事件

绘制的事件监听应该在 canvas 上还是在 document 上?应该是 document,因为:

如果监听在 document 上,我们怎么判断用户是否预期在画布上绘制?如果有画布重叠,是想在哪个画布上绘制?

我们可以给 canvas 添加 touchstart 事件,只有在 touchstart 后,才向 document 添加 touchmove 等事件,这样就不会混淆画布绘制了。

取得相对画布的坐标点

用户的指针到底在目标 canvas 上的哪里?因为我们用的是 document 上的事件,TouchEvent 中不会有相应的信息,因此,我们需要自己根据 client 相关的信息来计算:

const { clientX, clientY } = event.changedTouches[0];
const { x, y } = canvas.getBoundingClientRect();
const point = {
    x: clientX - x,
    y: clientY - y,
}

但是这个方案并不完美,当元素有 css transform 时,得到的值并非实际在 Canvas 上的值。因此,这个元素不能有缩放和旋转,否则位置计算不正确。 我们可以应用变换矩阵来处理这种情况,但以目前的浏览器能力,没有办法获得一个元素实际的变换矩阵。即使有,还有 transform 3D + perspective 需要处理... 暂时先不考虑这种情况。 如果是鼠标事件,最新浏览器标准中有 offsetX 与 offsetY,考虑了这些 transformation。但在 Touch 中是没有的。

scale + rotate 的画布,一种未解决的 badcase

转译指针事件

鼠标事件和触控事件不一致, 我们的画板是触控优先的,但也应当兼容其他指针事件。

浏览器有自动事件转换,这也是即使我们的元素只监听 mousedown,在触屏设备上点击也能正常工作的原因。一般来说,转换顺序是 pointer events > touch events > mouse events,如果一种事件没有处理,浏览器会自动转译为后续同类事件。详情可以 参考这个测试[2]。

但浏览器不会帮我们把非触控事件转换为触控事件,因此需要我们手动转译。

export function pointerToTouchInit(e: PointerEvent): TouchInit {
    const { clientX, clientY, pageX, pageY, target, screenX, screenY } = e;
    return {
        clientX,
        clientY,
        pageX,
        pageY, 
        target, 
        screenX,
        screenY,
        // 我们加一些触控特有的模拟属性
        force: 1,
        radiusX: 0,
        radiusY: 0,
        identifier: Infinity,
        rotationAngle: 0,
    };
}

export function pointerToTouchAdapter(getEventHandler: () => TouchAdapter) {
    return (e: PointerEvent) => {
        const isTouch = e.pointerType === 'touch';
        if (isTouch) {
            // 触控事件由 touch 系列事件解决,不需要转译
            return;
        }
        const { touchStart, touchMove, touchEnd, touchCancel } = getEventHandler();
        const touchInit = mouseToTouchInit(e);
        const init: TouchEventInit = {
            touches: [new Touch(touchInit)],
            changedTouches: [new Touch(touchInit)],
        };

        const typeMap = {
            pointerdown: ['touchstart', touchStart],
            pointermove: ['touchmove', touchMove],
            pointerup: ['touchend', touchEnd],
            pointercancel: ['touchcancel', touchCancel],
        } as const;
        const { type } = e;
        if (!(type in typeMap)) {
            return;
        }
        const next = typeMap[type as keyof typeof typeMap];
        const [newEvent, eventHandler] = next;
        const touchEvent = new TouchEvent(newEvent, init);
        // 直接调用对应 event 的 handler,就不手动 dispatchEvent 了
        eventHandler(touchEvent);
    };
}

初步绘制

在前面的步骤中,我们已经得到了可以绘制在画布上的坐标点,只需将点连成线,绘制在画布上即可。

const ctx = canvas.getContext('2d');

ctx.lineCap = 'round';
ctx.lineJoin = 'round';

// 存一下上次的 offsetX 和 offsetY
let prev = { offsetX: 0, offsetY: 0 };

// touchstart
ctx.beginPath();
prev.offsetX = touch.offsetX;
prev.offsetY = touch.offsetY;

// touchstart, touchmove
ctx.moveTo(prev.offsetX, prev.offsetY);
ctx.lineTo(touch.offsetX, touch.offsetY);

// touchend,在这里 stroke 可以一次把路径绘制在画布上
ctx.lineWidth = 4;
ctx.strokeStyle = '#66ccff';
ctx.stroke();

绘制流程

好像我们刚刚已经把路径绘制到了画布上,是不是此时就完成了呢?不,远远没有! canvas 对于我们来说是输出设备,一旦输出给它,信息就丢失而读不回来了。 比如,当 canvas 发生 resize 时,它的 context 会被销毁并重建,我们就丢失了绘制在它上面的所有信息。 再比如,我们还需要实现撤销 / 重做功能,如果记录的内容是笔迹的路径信息,我们才能随时重放。 因此,我们需要将绘制所需的信息存储在一个数据结构中,而不是将绘制结果存储在画布上。

存储绘制过程

为了存储我们的绘制过程,我们首先需要一个类来管理我们的 canvas 和绘制。我们将它命名为 CanvasDescriptor。

export class CanvasDescriptor {
    canvas: HTMLCanvasElement;

    paths: unknown[];
    path: unknown;

    draw(path: unknown) {
        //
    }
}

简单查找,找到 Path2D这一API[3],可以替换 ctx.moveTo 来记录我们的绘制信息,且可以随时通过 ctx.draw 画在 canvas 上。因此,我们只要稍稍改造我们的代码来使用这个数据结构。

const desc = new CanvasDescriptor(canvas);

// touchStart
desc.path = new Path2D();

// touchStart, touchMove
desc.path.moveTo(prev.offsetX, prev.offsetY);
desc.path.lineTo(touch.offsetX, touch.offsetY);

// touchEnd
desc.paths.push(path);
desc.draw();

// CanvasDescriptor
ctx.beginPath();
ctx.lineWidth = 4;
ctx.strokeStyle = '#66ccff';
ctx.stroke(path);

但这时,新的问题出现了:

  1. 我们需要绘制不断变化的元素,比如,正在绘制中的 path、指针当前位置的指示器。一个静态数组不能支持我们绘制可变数据。
  2. Path2D 不能区别不同 path 的 颜色和宽度,只使用 path 绘制,会导致所有的笔迹粗细、颜色都相同。

我们将继续解决这些问题。

绘制流程设计

画布上的信息只是一个位图,输出给 canvas 的元素是不能撤销的,想要修改某一个元素,只能清空画布(相关的部分)并重绘。 因此,为了实现绘制临时笔迹绘制的功能,我们需要给 canvas 引入一个绘制流程,帮助绘制那些不断变化的元素。 我们将 path 分为 pending 和 committed 两类,没有触发 touchend 时为 pending 状态,触发后转换到 committed 状态。并将绘制过程设计为:

  1. 清空整块画布
  2. 绘制已经缓存的绘制
  3. 按顺序绘制 committed 数组中的笔迹
  4. 绘制 pending 的笔迹
  5. 绘制其他临时要素,如 当前指针位置 作为 绘制预览

这样,我们只需执行绘制过程,就可以随时更新画布上的信息了。

那么,这个绘制过程由谁来触发呢?我们有两种选择:

  1. 当绘制信息变更时同步触发,如 触控事件、撤销重做... 在画布信息的同时,手动调用一次绘制
  2. 定时触发,适合画布中的存在动画元素,即:元素会随着时间发生变化 的情况... 每次 requestAnimationFrame 并调用一次绘制

初步考虑在画板场景,笔迹一般是没有动画的,所以此时,我们选择方案 1,让 touch event 等直接同步触发绘制。

此时的绘制效果,已经可以绘制鼠标指针了

数据结构设计

  1. 为了让我们的绘制流程统一,我们设计一个抽象类 Drawable。在整个绘制流程中,凡是向画布绘制的,都通过 Drawable.draw() 方法操作。
export abstract class Drawable {
  // 绘制只需要 context,但在实践中发现,
  // 当 canvas DOM 被替换后 ctx 就不对了。
  // 因此,不能只存储当时的 ctx,
  // 而是存储一个获取 ctx 的方法
  ctx: CanvasRenderingContext2D;
  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
  }

  #desc: CanvasDescriptor;
  get ctx(): CanvasRenderingContext2D | null {
    return this.#desc.canvas?.getContext('2d') || null;
  }
  constructor(desc: CanvasDescriptor) {
    this.#desc = desc;
  }

  abstract draw(): void;
}

2 . 为了尽可能多地记录绘制过程(比如,未来可能会想做笔锋,那就需要绘制点的时间信息),我们设计一个数据结构,存储事件中除了位置外的其他信息,比如,时间、力度等。

export type TrackedDrawEvent = {
  top: number;
  left: number;
  force: number;
  time: number; // high res timer (ms)
};

并设计 RawDrawable extends Drawable,为记录绘制过程实现 track 和 commit 方法。

export class RawDrawable extends Drawable {
  events: TrackedDrawEvent[] = [];

  #startTime: number | null = null;

  #endTime: number | null = null;

  get duration() {
    if (this.#startTime == null) {
      return 0;
    }
    if (this.#endTime == null) {
      return performance.now() - this.#startTime;
    }
    return this.#endTime - this.#startTime;
  }

  track(touch: Touch) {
    if (this.#startTime === null) {
      this.#startTime = performance.now();
    }

    const { clientX, clientY, force } = touch;
    const { ctx } = this;
    const { canvas } = ctx;

    const { clientWidth, clientHeight } = canvas;
    const { top, left, width, height } = canvas.getBoundingClientRect();
    const scaleX = clientWidth / width;
    const scaleY = clientHeight / height;

    this.events.push({
      left: (clientX - left) * scaleX,
      top: (clientY - top) * scaleY,
      force,
      time: performance.now() - this.#startTime,
    });
  }

  commit() {
    this.#endTime = performance.now();
  }

  draw() {
    throw new Error(`I don't know how to draw`);
  }
}

3 . 为了支持 自由绘制以及存储绘制配置,设计 PathDrawable extends RawDrawable,并实现 draw 方法。

export type PathDrawableConfig = Pick<
  CanvasConfigType,
  'color' | 'eraserWidth' | 'lineWidth' | 'type'
>;

export class PathDrawable extends RawDrawable {
  config: PathDrawableConfig;

  constructor(desc: CanvasDescriptor, config: PathDrawableConfig) {
    super(desc);
    this.config = { ...config };
  }

  draw(events?: TrackedDrawEvent[]) {
    const { ctx, config } = this;
    if (!ctx) {
      return;
    }
    const { lineWidth, eraserWidth, color, type } = config;
    ctx.globalCompositeOperation =
      type === 'eraser' ? 'destination-out' : 'source-over';
    ctx.lineWidth = type === 'eraser' ? eraserWidth : lineWidth;
    ctx.strokeStyle = color;
    ctx.beginPath();
    (events ?? this.getEvents()).forEach(e => {
      ctx.lineTo(Math.round(e.left), Math.round(e.top));
    });
    ctx.stroke();
  }
}

同理,我们也可以继续实现 CacheDrawable extends Drawable、MousePositionDrawable extends RawDrawable、EraserDrawable extends PathDrawable 等等,每次按绘制流程执行对应的 draw 方法就可以了。

4 . 修改我们的使用方法

export class CanvasDescriptor {
  id: string;

  config: CanvasConfigType;

  mainCanvas: HTMLCanvasElement | null = null;

  pending: Drawable[] = [];
  committed: Drawable[] = [];

  constructor(id: string, config?: CanvasConfigType) {
    this.id = id;
    this.config = config ?? defaultCanvasConfig;
  }

  draw() {
    const pendingDrawable = [...this.acceptedTouches.values()];
    const canvas = this.mainCanvas;

    if (!canvas || canvas.height === 0 || canvas.width === 0) {
      // 画布不存在,不用绘制了
      return false;
    }

    const ctx = canvas.getContext('2d');
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    // clear canvas
    new ClearDrawable(this).draw();
    // draw cache
    const cache = new CacheDrawable(this);
    cache.draw();
    cache.update();
    // draw committed
    this.committed.forEach(path => {
      path.draw();
    });
    // draw pending
    this.pending.forEach(path => {
      path.draw();
    });
    // draw indicator, 代码略
    return true;
  }
}
// touch start
path = new PathDrawable(this);
path.track(e);

// touch move
path.track(e);

// touch end
path.commit();
paths.push(path);

desc.draw();

画布配置

我们已经在 PathDrawable 为路径预留了一部分 config。这里,我们设计画布的配置。画布配置的更新只影响后续绘制的路径,而不影响已经绘制好的笔迹,因此配置变更,不会导致画布重新绘制。

export type BrushType = 'pen' | 'eraser' | 'chalk' | 'stroke';
export type CanvasState = 'normal' | 'locked' | 'hidden';

export type CanvasConfigType = {
  color: string;
  lineWidth: number;
  eraserWidth: number;
  type: BrushType;
  canvasState: CanvasState;
};

在 new Drawable() 时,将 config 传入对应构造函数中即可。

多指绘制

在触控设备上,用户很可能会多指同时在屏幕上绘制。这些绘制可能在同一块画布上,也可以分别在不同的画布上,因此,我们需要做一些操作来支持多指绘制。

// class CanvasDescriptor 
acceptedTouches: Map<number, RawDrawable> = new Map(); 
  1. 对于触控事件来说,changedTouches: TouchList 里面包含所有改变的触控信息。
  2. 其中的每一个 Touch,identifier 可唯一标识这个触控
  3. 当 touchstart 时,将 identifier 即对应的 RawDrawable 存入 acceptedTouches
  4. 按照我们的设计,touchmove 和 touchend 是绑定在 document 上的,因此必须检测这个事件是否来自 accepted 的 pointer。如果不是,可能是来自其他画板实例,或不在画板上,因此忽略即可。
  5. touchend / touchcancel / touchleave 事件,需要从 acceptedTouches 中移除,防止 identifier 被复用而错误判断。
// touch start
const touches = event.changedTouches;
for (let i = 0; i < touches.length; i++) {
  const touch = touches[i];
  const accepted = new PathDrawable(this);
  acceptedTouches.set(touch.identifier ?? Infinity, accepted);
  accepted.track(event);
} 
updateCanvas();

// touch move,对每一个 Touch
const id = touch.identifier ?? Infinity;
const accepted = acceptedTouches.get(id);
if (accepted) {
  accepted.track(touch, rect);
} else {
  // not accepted
}

// touch end,对每一个 Touch
if (accepted) {
  accepted.commit(touch, rect);
  this.committed.push(accepted);
  acceptedTouches.delete(id);
}

当然,我们还需要对多指操作进行兼容,比如我们的指针位置指示器,有可能需要指示更多的指针位置。撤销重做操作时,有可能需要同时撤销/重做多指绘制,这些我们暂时略过。

擦除

擦除分为两种模式。

路径擦除

路径擦除需要碰撞检测算法,将鼠标当前位置与画布中的每一个 path 进行比较。可参考这里的实现。 四叉树碰撞检测[4]

位图擦除

我们存储的数据结构是路径,要想进行位图擦除,需要先得到位图。回顾我们的绘制过程,已经绘制到 canvas 的路径就是位图,因此,我们的橡皮擦应当像 PathDrawable 一样,绘制在 Canvas 上。

那么如何擦除呢? canvas 提供了混合模式的设置,混合模式,是指即将绘制的内容与画布已有内容的交互方式。用过 PhotoShop 的同学应该会比较熟悉。而画布 canvas 也提供了一些混合方式。 https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation [5]

globalCompositeOperation = destination-out 模式就是将目标重叠的部分从画布上擦除。

因此,擦除对于我们来说也是一种绘制。无需修改绘制流程,只需在 PathDrawable 的 config 提供 eraser 模式,并改造 draw 方法,使其根据 config 调整 globalCompositeOperation 即可。

// PathDrawable.draw()
ctx.globalCompositeOperation =
      type === 'eraser' ? 'destination-out' : 'source-over';
ctx.lineWidth = type === 'eraser' ? eraserWidth : lineWidth;

同时改造一下我们的指针位置指示器,让它在绘制橡皮擦时有 60% 的透明度,以确保我们能看清楚要擦除的内容。

擦除支持与改造后的指示器

性能优化

按照上述方案,无论是绘制,还是擦除,对于我们来说都是绘制,都会导致 committed 数组中的 Drawable 越来越多。而我们的绘制流程每次都会清空全部笔迹并重新绘制,复杂度随绘制笔画线性增加。在 500 * 500 * 2 * 2(dpr) 的 canvas 上,350 笔画后,绘制和擦除将会有明显卡顿。

因此,我们需要对流程进行优化,确保我们的绘制复杂度不会无限增长。

缓存模式

将一个 canvas 绘制到另一个 canvas 上时是同步的,因此,我们可以创建一个新的 canvas 用来绘制缓存内容。我们添加 CacheDrawable extends Drawable,并补充至绘制流程。

// class CanvasDescriptor

// rename
// canvas => mainCanvas

get cacheCanvas(): HTMLCanvasElement | null {
  const { mainCanvas } = this;
  if (!mainCanvas) {
    return null;
  }
  const cacheCanvas =
    mainCanvas.cacheCanvas || document.createElement('canvas');

  if (
    cacheCanvas.height !== mainCanvas.height ||
    cacheCanvas.width !== mainCanvas.width
  ) {
    this.rasterizedLength = 0;
    cacheCanvas.height = mainCanvas.height;
    cacheCanvas.width = mainCanvas.width;
  }

  mainCanvas.cacheCanvas = cacheCanvas;
  return cacheCanvas;
}

// 已经缓存的绘制长度,对应 committed 数组
rasterizedLength: number = 0;

draw() {
  // draw committed,每次绘制时,只绘制尚未缓存的部分
  this.committedDrawable.slice(this.rasterizedLength).forEach(path => {
    path.draw();
  });
}

CacheDrawable 的实现:

const BUFFER_SIZE = 2;
const MIN_THRESHOLD = 2;

export class CacheDrawable extends Drawable {
  desc: CanvasDescriptor;

  constructor(desc: CanvasDescriptor) {
    super(desc);
    this.desc = desc;
  }

  draw() {
    const { ctx, desc } = this;
    if (!desc.rasterizedLength || !ctx) {
      return;
    }
    const { mainCanvas, cacheCanvas } = desc;
    if (!mainCanvas || !cacheCanvas) {
      return;
    }
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.globalCompositeOperation = 'source-over';
    ctx.drawImage(
      cacheCanvas,
      0,
      0,
      mainCanvas.clientWidth,
      mainCanvas.clientHeight,
    );
  }

  update() {
    const { desc } = this;
    if (
      desc.committedDrawable.length >=
      desc.rasterizedLength + BUFFER_SIZE + MIN_THRESHOLD
    ) {
      const before = desc.rasterizedLength;
      const after = desc.committedDrawable.length - BUFFER_SIZE; // after > before
      const toRasterize = desc.committedDrawable.slice(before, after);
      toRasterize.forEach(path => {
        path.draw();
      });
      const canvas = desc.mainCanvas!;
      const cacheCanvas = desc.cacheCanvas!;
      const cacheContext = cacheCanvas.getContext('2d')!;
      cacheContext.imageSmoothingEnabled = true;
      cacheContext.imageSmoothingQuality = 'high';
      cacheContext.clearRect(0, 0, cacheCanvas.width, cacheCanvas.height);
      cacheContext.drawImage(
        canvas,
        0,
        0,
        cacheCanvas.width,
        cacheCanvas.height,
      );
      desc.rasterizedLength = after;
    }
  }
}

我们并没有丢弃已缓存的绘制信息,而是用一个指针 rasterizedLength 指向了未缓存的绘制。

因为,如果将它们丢弃,可能有一些问题,比如在实现撤销操作时,会出现没有足够的信息可供撤销。虽然提高 BUFFER_SIZE,并限制用户的撤销次数,比如只允许用户进行 100 次撤销,一般也足够用了。但还有另一个原因更重要的原因!我们之前说过,当 dpr 变化,会导致 canvas 的 width 和 height 属性变化时,进而会导致 canvas 被全部重置。如果我们已经丢弃了之前的绘制信息,就没有机会重绘这些内容了。如果用户在绘制时 dpr 发生变化,绘制的一部分内容就会突然消失,而且我们也没办法将它们找回来了。

那么,存储这么多笔迹会不会有内存问题?暂时不用担心。我们的笔迹占用的存储空间很小。

缓存后,整体绘制是比较快的,且可以预期,绘制所需时间不会随着绘制步骤继续增长。

离屏 Canvas

缓存的 Canvas 也可以直接换用 OffscreenCanvas。不过,一方面现在的性能已经足够好了,另一方面,在同一个线程中,收益不大,主要是为了在 web worker 中可用。

缓存带来的问题

我们的性能测试是以在 500 * 500 * 2 * 2 (dpr) 画布上进行的,倘若这个 canvas 非常大(如 5000 * 5000),我们的缓存反而会导致性能变得非常差。

用缓存,在 10 笔画后已经达不到 10 fps 不用缓存,性能还稍好一些

这是因为,我们在读/写缓存时,操作的是整个画布,可是我们实际更新的区域并不是整个画布,读写缓存需要将大量没有更新的内容重绘一遍,反而拖累了绘制性能。

最直接的优化方案:这是由于超大画布引起的,因此,我们降低画布的 dimension。

1 . 假设 canvas 的 client size 不能变,我们的 canvas size = client size * dpr,因此,可以降低 DPR。这样绘制虽然会稍有模糊,但至少不会卡顿。

2 . 优化 client size。超大画布没有实际应用价值,绝大多数内容都不在屏幕上,因此,我们将画布大小固定为显示区域大小,监听外部容器的滚动事件,并随之滚动画布的绘制区域。这样,我们可以在不降低 dpr 的前提下优化画布绘制。

还有一些解决方案...

3 . 已知读取和写入缓存性能最差,我们可以这样优化:

4 . 绘制 Path 性能较差,可以这样优化:

详细了解,可以参考:

https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas [6]

多画板下的信息存储

我们可能存在多块画板共存的的情况,而对于需要翻页的黑板来说,画板组件需要在销毁重建时保留笔迹。因此,我们将数据存储提升在 context 中。

每一块画板拥有自己的 key,当画板初始化时(对应 React 的 useEffect),向 context 注册自己的实例,并更新属于自己的数据。

画板销毁时,向 context 标注自身已被销毁,但并不清除数据,对应需要保存笔迹的场景。

此处比较简单,复杂的是在多画板场景下,后续撤销、重做、清空的支持。

画布整体操作 - 锁定,撤销,重做,清空

锁定与隐藏绘制

在 context 中,新增一个控制参数

export type CanvasState = 'normal' | 'locked' | 'hidden'; 

令画板组件响应该参数变化。当为 locked 时,置 画板图层 pointer-events: none,当为 hidden 时,也置画板图层 opacity: 0 或置 visibility: none。

撤销和重做

单画布的撤销重做

在我们的绘制流程中,每次都会重绘所有路径,因此对于单画布来说,撤销只需跳过这些路径的绘制即可。 我们添加一个新属性,

let afterUndoLength: number | undefined = undefined; 

这样命名,是让它与数组 length 协同工作。无论何时,如果 afterUndoLength === undefined,则 afterUndoLength = committed.length。

当 undo 时

当 redo 时

当读取 committed 数组的数据时,不返回被撤销的部分

当写入 committed 数据前,先清空重做队列

按上述思路,我们给绘制流程添加一些属性和方法。

export class CanvasDescriptor {
  #committed: RawDrawable[] = [];

  committedDrawable: Drawable[];

  afterUndoLength: number | undefined = undefined;

  constructor(id: string, config?: CanvasConfigType) {
    this.id = id;
    this.config = config ?? defaultCanvasConfig;

    const desc = this;
    this.committedDrawable = new Proxy(this.#committed, {
      get(t, p, r) {
        // get 的时候,告诉对方已经撤销的数组部分不存在
        if (p === 'length' && desc.afterUndoLength != null) {
          return desc.afterUndoLength;
        }
        return Reflect.get(t, p, r);
      },
      set(t, p, v, r) {
        // set 的时候,
        // 清空已经撤销的数据
        if (p !== 'length') {
          desc.afterUndoLength = undefined;
        }
        return Reflect.set(t, p, v, r);
      },
    });
  }

  undo() {
    if (this.afterUndoLength == null) {
      this.afterUndoLength = this.#committed.length;
    }
    if (this.afterUndoLength <= 0) {
      return false;
    }
    this.afterUndoLength -= 1;
    if (this.rasterizedLength > this.afterUndoLength) {
      this.rasterizedLength = 0;
    }
    return true;
  }

  redo() {
    if (
      this.afterUndoLength == null ||
      this.afterUndoLength >= this.#committed.length
    ) {
      return false;
    }
    this.afterUndoLength += 1;
    return true;
  }
}
整体撤销重做

现在,我们的绘制数据是每块画板的数据是分别存储,当多块画布数据改变时,应该如何整体撤销呢?此时可以选择两种方法:

  1. 聚合所有画布的数据更新到同一个数组,然后通过一个 proxy,为每一块画布返回自己的数据
  2. 保持现有的数据结构,额外在 context 中添加更新记录,记录每次更新时是哪一块画板,当撤销/重做时,先通过这个数据结构找到画板,再对目标画板进行操作。

最终我们选择方案 2,因为可能出现一次修改多块画板的情况。

为此,我们给 canvas 添加属性,用来通知 context 自己的数组有变化:

export class CanvasDescriptor {
  onCommittedUpdated?: (target: CanvasDescriptor) => void;

  constructor(id: string, config?: CanvasConfigType) {
    this.id = id;
    this.config = config ?? defaultCanvasConfig;

    // eslint-disable-next-line consistent-this, @typescript-eslint/no-this-alias
    const desc = this;
    this.committedDrawable = new Proxy(this.#committed, {
      get(t, p, r) {
        // get 的时候,骗对方说已经撤销的步骤不存在
        if (p === 'length' && desc.afterUndoLength != null) {
          return desc.afterUndoLength;
        }
        return Reflect.get(t, p, r);
      },
      set(t, p, v, r) {
        if (p !== 'length') {
          desc.onCommittedUpdated?.(desc);
          desc.afterUndoLength = undefined;
        }
        return Reflect.set(t, p, v, r);
      },
    });
  }
}

context 在注册画布时,为其提供对应更新函数,调用时,写入 modifiedCanvas 数组。撤销重做操作与单画布类似,也是 proxy + 判断。

在整体中指定某些画板撤销重做

指定某些画板撤销,需要反向搜索 modifiedCanvas 队列,将目标 canvas 重排序至队尾,并执行撤销操作。因为画板间的绘制是独立的,因此重排序并不会影响整体绘制效果,也可以确保重做功能正常运行。

清空与取消注册

我们之前提到 ClearDrawable,现在可以用上了。为了让撤销重做的逻辑简单,清空时也是向目标画板的绘制序列插入一个特殊绘制指令。

export class ClearDrawable extends Drawable {
  draw() {
    const { ctx } = this;
    if (!ctx) {
      return;
    }
    const { canvas } = ctx;
    const { clientWidth, clientHeight } = canvas;
    ctx.clearRect(0, 0, clientWidth, clientHeight);
  }
}

清空操作可能是一次控制多个画板,这是我们之前将 modifiedCanvas 设计为 (string[])[] 的原因。

此时撤销重做清空的效果 另外,我们之前在画板组件销毁时,并没有在 context 中清空它的数据。而清空操作,也只是 push 了新的绘制指令,因此,我们需要另外实现一个 hard clear 功能,即 unregister,彻底删除某个画板在 context 中的数据。

对于 hard clear,我们需要处理撤销重做队列,移除那些被彻底清除的画布的绘制记录,同时需要修改 afterUndoLength (如果存在)。

笔刷扩展

我们在 RawDrawable 中存储了足够的信息,因此可以对笔刷进行扩展。

一般形状的绘制

一般形状的绘制比较简单,只需将 RawDrawable 中的信息取出一部分进行绘制即可。如 直线、圆、矩形 只需取头尾两个点,三角形可以通过双指手势、平滑算法等取 3 个点。

笔锋

对于笔锋效果,时间成了影响整个 Path 的因子,并通过一定的算法来生成整个绘制过程。

带有笔锋的笔迹(算法是凭感觉写的,仅供演示) 绘制对比

粉笔

算法参考[7] 如果算法不是稳定的(例如,有随机因子),那么我们在绘制时也需要存储这些随机因子,否则在 update canvas 的时候,如果随机因子变了,笔迹会发生变化,这是不符合预期的。

因此,我们需要提供自己的稳定随机数生成算法来替代 Math.random。可以参考 https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript [8]

另外这种算法使用了 clearRect 来产生透明度,为了避免它擦掉我们的背景,我们需要将它绘制在隔离的 context 上,再转移到我们的画布上。

// 随机数生成器
function mulberry32(a: number) {
  return function () {
    let t = (a += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export class ChalkDrawable extends RawDrawable {
  config: ChalkDrawableConfig;
  seed: number;

  constructor(ctx: CanvasRenderingContext2D, config: ChalkDrawableConfig) {
    super(ctx);
    this.config = { ...config };
    // 固定种子
    this.seed = Number(`${Math.random()}`.slice(2));
  }

  draw(events?: TrackedDrawEvent[]) {
    const { ctx, config } = this;
    const { lineWidth, color } = config;

    const { clientWidth, clientHeight, width, height } = ctx.canvas;
    const offscreen = new OffscreenCanvas(width, height);
    const offscreenCtx = offscreen.getContext('2d')!;
    offscreenCtx.scale(width / clientWidth, height / clientHeight);

    const originalColor = Color(color);

    offscreenCtx.fillStyle = originalColor.setAlpha(0.5).toHex8String();
    offscreenCtx.strokeStyle = originalColor.setAlpha(0.5).toHex8String();
    offscreenCtx.lineWidth = lineWidth;
    offscreenCtx.lineCap = 'round';

    let xLast: number | null = null;
    let yLast: number | null = null;

    const random = mulberry32(this.seed);

    function drawPoint(x: number, y: number) {
      if (xLast == null || yLast == null) {
        xLast = x;
        yLast = y;
      }
      offscreenCtx.strokeStyle = originalColor
        .setAlpha(0.4 + random() * 0.2)
        .toHex8String();
      offscreenCtx.beginPath();
      offscreenCtx.moveTo(xLast, yLast);
      offscreenCtx.lineTo(x, y);
      offscreenCtx.stroke();

      const length = Math.round(
        Math.sqrt(Math.pow(x - xLast, 2) + Math.pow(y - yLast, 2)) /
          (5 / lineWidth),
      );
      const xUnit = (x - xLast) / length;
      const yUnit = (y - yLast) / length;
      for (let i = 0; i < length; i++) {
        const xCurrent = xLast + i * xUnit;
        const yCurrent = yLast + i * yUnit;
        const xRandom = xCurrent + (random() - 0.5) * lineWidth * 1.2;
        const yRandom = yCurrent + (random() - 0.5) * lineWidth * 1.2;
        offscreenCtx.clearRect(
          xRandom,
          yRandom,
          random() * 2 + 2,
          random() + 1,
        );
      }

      xLast = x;
      yLast = y;
    }

    (events ?? this.getEvents()).forEach(e => {
      drawPoint(e.left, e.top);
    });
    ctx.globalCompositeOperation = 'source-over';
    ctx.drawImage(offscreen, 0, 0, clientWidth, clientHeight);
  }
}

源码

我们在文章中隐去了一些实现细节,可以在完整代码[9]中详细了解。

参考资料

[1] 参考这里: https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio

[2] 参考这个测试: https://patrickhlauke.github.io/touch/tests/results/

[3] 这一API: https://developer.mozilla.org/en-US/docs/Web/API/Path2D

[4] 四叉树碰撞检测: https://timohausmann.github.io/quadtree-js/simple.html

[5] https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation : https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation

[6] https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas : https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas

[7] 算法参考: https://github.com/mmoustafa/Chalkboard

[8] https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript : https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript

[9] 完整代码: https://github.com/byted-meow/canvas-showcase

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8