從零實現並擴展可自由繪製的畫板

前言

作爲一個跑在教室大屏幕上的系統,免不了會與畫板打交道。實現一個優秀的畫板,可以很好地爲在線教學場景提供幫助。我們今天就從 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 class> <!-- 1. 這個容器用來響應寬度高度變化 -->
    <div class> <!-- 3. 使 canvas 脫離文檔流,且不影響佈局 -->
        <canvas width={dimension.width} height={dimension.height}> 
    </div>
    <div class> <!-- 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;
}
  1. 爲了儘可能多地記錄繪製過程(比如,未來可能會想做筆鋒,那就需要繪製點的時間信息),我們設計一個數據結構,存儲事件中除了位置外的其他信息,比如,時間、力度等。
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`);
  }
}
  1. 爲了支持 自由繪製以及存儲繪製配置,設計 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 方法就可以了。

  1. 修改我們的使用方法
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 的前提下優化畫布繪製。

還有一些解決方案...

  1. 已知讀取和寫入緩存性能最差,我們可以這樣優化:
  1. 繪製 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] 中詳細瞭解。

❤️ 謝謝支持

以上便是本次分享的全部內容,希望對你有所幫助 ^_^

喜歡的話別忘了 分享、點贊、收藏 三連哦~。

歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~

參考資料

[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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bJPNGmW3FpOwGWp29UHbzQ