从零实现并扩展可自由绘制的画板
前言
作为一个跑在教室大屏幕上的系统,免不了会与画板打交道。实现一个优秀的画板,可以很好地为在线教学场景提供帮助。我们今天就从 0 开始,实现一个可以自由绘制的 Canvas 画板。
目标有以下几点:
- 可自定义颜色粗细的笔迹绘制、擦除、撤销、重做、清空、数据存储
- 支持触摸与鼠标操作,支持多指触控
- 高性能绘制,不掉帧
- 自适应布局,无需指定宽高
- 提供可扩展性,如绘制粉笔笔迹、带笔锋的笔迹
好吧,话不多说,我们开始实现。
devicePixelRatio
dpr (device pixel ratio) 应该是逢 canvas 必涉及的问题了,画布绘制出来的东西模糊?那很可能就是 dpr 没有正确处理。关于 dpr 的详细解释,可以参考这里[1](当然,还可能是绘制文字时没有设置抗锯齿、绘制图片时没有设置平滑)。
window.devicePixelRatio === 4 | |
---|---|
处理 dpr,笔迹非常清晰 | 未处理 dpr,笔迹较为模糊 |
因此我们需要:
- 获得它 window.devicePixelRatio
- 监听并响应它的变化 (没错,它是会变的。当浏览器从一块屏幕移动到另一块屏幕、使用 Command + + 缩放等都可能导致 dpr 发生变化)
- 根据它的变化修改画布的宽度和高度
- 缩放 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 需要指定宽度、高度才能工作,在实际使用场景中,容器宽高通常是不定的,因此,我们需要对画布进行布局。
一个外层响应式容器,用于探测父容器及内容的宽度和高度,使用 ResizeObserver 监视它的宽高变化,并更新画布元素的宽高。
一个容器来存放其他内容,比如在线教学时,老师可能会在学生作答上绘制,因此,画板是与其他元素叠加显示的。
最终,我们将 DOM 元素布局为
<div className="container">
<div className="canvas">
<canvas width={dimension.width} height={dimension.height}>
div>
<div className="content">
{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 ) {
const [dimension, setDimension] = useState({
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 上 | badcase,在 canvas 上 | | --- | --- | | | |
如果监听在 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);
但这时,新的问题出现了:
- 我们需要绘制不断变化的元素,比如,正在绘制中的 path、指针当前位置的指示器。一个静态数组不能支持我们绘制可变数据。
- Path2D 不能区别不同 path 的 颜色和宽度,只使用 path 绘制,会导致所有的笔迹粗细、颜色都相同。
我们将继续解决这些问题。
绘制流程设计
画布上的信息只是一个位图,输出给 canvas 的元素是不能撤销的,想要修改某一个元素,只能清空画布(相关的部分)并重绘。
因此,为了实现绘制临时笔迹绘制的功能,我们需要给 canvas 引入一个绘制流程,帮助绘制那些不断变化的元素。
我们将 path 分为 pending 和 committed 两类,没有触发 touchend 时为 pending 状态,触发后转换到 committed 状态。并将绘制过程设计为:
- 清空整块画布
- 绘制已经缓存的绘制
- 按顺序绘制 committed 数组中的笔迹
- 绘制 pending 的笔迹
- 绘制其他临时要素,如 当前指针位置 作为 绘制预览
这样,我们只需执行绘制过程,就可以随时更新画布上的信息了。
那么,这个绘制过程由谁来触发呢?我们有两种选择:
- 当绘制信息变更时同步触发,如 触控事件、撤销重做... 在画布信息的同时,手动调用一次绘制
- 定时触发,适合画布中的存在动画元素,即:元素会随着时间发生变化 的情况... 每次 requestAnimationFrame 并调用一次绘制
初步考虑在画板场景,笔迹一般是没有动画的,所以此时,我们选择方案 1,让 touch event 等直接同步触发绘制。
此时的绘制效果,已经可以绘制鼠标指针了
数据结构设计
- 为了让我们的绘制流程统一,我们设计一个抽象类 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;
}
- 为了尽可能多地记录绘制过程(比如,未来可能会想做笔锋,那就需要绘制点的时间信息),我们设计一个数据结构,存储事件中除了位置外的其他信息,比如,时间、力度等。
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`);
}
}
- 为了支持 自由绘制以及存储绘制配置,设计 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 方法就可以了。
- 修改我们的使用方法
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();
- 对于触控事件来说,changedTouches: TouchList 里面包含所有改变的触控信息。
- 其中的每一个 Touch,identifier 可唯一标识这个触控
- 当 touchstart 时,将 identifier 即对应的 RawDrawable 存入 acceptedTouches
- 按照我们的设计,touchmove 和 touchend 是绑定在 document 上的,因此必须检测这个事件是否来自 accepted 的 pointer。如果不是,可能是来自其他画板实例,或不在画板上,因此忽略即可。
- 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 接触时,移除整个 path,适合在书写笔迹时使用。
- 位图擦除,橡皮擦将画布上的所有内容视为位图,擦除与之接触的部分,适合在绘制时使用。
路径擦除
路径擦除需要碰撞检测算法,将鼠标当前位置与画布中的每一个 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。
- 假设 canvas 的 client size 不能变,我们的 canvas size = client size * dpr,因此,可以降低 DPR。这样绘制虽然会稍有模糊,但至少不会卡顿。
- 优化 client size。超大画布没有实际应用价值,绝大多数内容都不在屏幕上,因此,我们将画布大小固定为显示区域大小,监听外部容器的滚动事件,并随之滚动画布的绘制区域。这样,我们可以在不降低 dpr 的前提下优化画布绘制。
还有一些解决方案...
- 已知读取和写入缓存性能最差,我们可以这样优化:
- 绘制不再由触摸事件触发,而是定时 requestAnimationFrame 触发
- 每次记录将要更新的 object,并获取它更新前后的 Rect,我们只读取 / 更新这一部分的缓存
- 将不变的内容绘制在其他 canvas 上,叠加另一个 canvas 绘制变化的内容
- 绘制 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 时
- 如果 afterUndoLength > 0,则
- 令 afterUndoLength -= 1
- 如果 afterUndoLength 小于 rasterizedLength,说明我们已经撤销了太多了,需要放弃缓存,重绘整个操作队列。令 rasterizedLength = 0。
当 redo 时
- 如果 afterUndoLength 不是 undefined
- 令 afterUndoLength += 1
当读取 committed 数组的数据时,不返回被撤销的部分
- slice(0, afterUndoLength)
当写入 committed 数据前,先清空重做队列
- 令 committed.length = afterUndoLength ?? committed.length
- 令 afterUndoLength = undefined
按上述思路,我们给绘制流程添加一些属性和方法。
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;
}
}
整体撤销重做
现在,我们的绘制数据是每块画板的数据是分别存储,当多块画布数据改变时,应该如何整体撤销呢?此时可以选择两种方法:
- 聚合所有画布的数据更新到同一个数组,然后通过一个 proxy,为每一块画布返回自己的数据
- 保持现有的数据结构,额外在 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