探究 canvas 繪圖中撤銷(undo)功能的實現方式

最近在作網頁版圖片處理相關的項目,也算是初入了 canvas 的坑。項目需求中有一個給圖片添加水印的功能。咱們知道,在瀏覽器端實現圖片添加水印功能,一般的作法就是使用 canvasdrawImage 方法。對於普通的合成(好比一張底圖和一張 PNG 水印圖片合成)來講,其大體實現原理以下:前端

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext('2d');

// img: 底圖
// watermarkImg: 水印圖片
// x, y 是畫布上放置 img 的座標
ctx.drawImage(img, x, y);
ctx.drawImage(watermarkImg, x, y);

直接連續使用 drawImage() 把對應的圖片繪製到 canvas 畫布上就行。canvas

以上就是背景介紹。可是略麻煩的是添加水印的需求中還有一個須要實現的功能是用戶可以切換水印的位置。咱們天然會想到可否實現 canvasundo 功能,當用戶切換水印位置時,先撤銷上一步 drawImage 操做,而後再從新繪製水印圖片位置。數組

restore/save ?

效率最高也是最方便的確定是查閱 canvas 2D 原生 API 是否有此功能。通過一番搜索,restore/save 這一對 API 進入視線。咱們先看一下這兩個 API 的描述:瀏覽器

CanvasRenderingContext2D.restore() 是 Canvas 2D API 經過在繪圖狀態棧中彈出頂端的狀態,將 canvas 恢復到最近的保存狀態的方法。 若是沒有保存狀態,此方法不作任何改變。

CanvasRenderingContext2D.save() 是 Canvas 2D API 經過將當前狀態放入棧中,保存 canvas 所有狀態的方法。性能優化

乍看起來能夠知足需求。咱們看一下官方示例代碼:app

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

ctx.save(); // 保存默認的狀態
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 100, 100);

ctx.restore(); // 還原到上次保存的默認狀態
ctx.fillRect(150, 75, 100, 100);

結果以下圖所示:jsp

圖片描述

奇怪,好像和咱們預期的結果不太一致。咱們想要的結果是 save 方法調用後可以保存當前畫布的快照,resolve 方法調用後可以徹底回到上一個保存的快照處的狀態。wordpress

再仔細研究一下 API。原來咱們遺漏一個重要概念:drawing state,也就是繪製狀態。保存到棧中的繪製狀態包含如下幾個部分:性能

  • 當前的變換矩陣
  • 當前的剪切區域
  • 當前的虛線列表
  • 如下屬性當前的值:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled.

好吧,drawImage 操做後對畫布的改變根本不存在於繪製狀態中。因此,使用 resolve/save 沒法實現咱們須要的 undo 功能。測試

模擬棧實現

既然原生的 API 保存繪製狀態的棧沒法知足需求,那麼天然咱們會想到本身模擬一個保存操做的棧。隨之而來的問題就是:每次繪製操做以後,應該保存什麼數據進棧?前面說過,咱們想要的是每步繪製操做以後可以保存當前畫布的快照,若是能拿到快照數據,同時能利用快照數據恢復畫布的話,問題也就迎刃而解了。

幸運的是 canvas 2D 原生提供了獲取快照和經過快照恢復畫布的 API ——getImageData/putImageData。如下是 API 說明:

/*
 * @param { Number } sx 將要被提取的圖像數據矩形區域的左上角 x 座標
 * @param { Number } sy 將要被提取的圖像數據矩形區域的左上角 y 座標
 * @param { Number } sw 將要被提取的圖像數據矩形區域的寬度
 * @param { Number } sh 將要被提取的圖像數據矩形區域的高度
 * @return { Object } ImageData 包含 canvas 給定的矩形圖像數據
 */
 ImageData ctx.getImageData(sx, sy, sw, sh);
 
 /*
 * @param { Object } imagedata 包含像素值的對象
 * @param { Number } dx 源圖像數據在目標畫布中的位置偏移量(x 軸方向的偏移量)
 * @param { Number } dy 源圖像數據在目標畫布中的位置偏移量(y 軸方向的偏移量)
 */
 void ctx.putImageData(imagedata, dx, dy);

咱們來看一個簡單的應用方式:

class WrappedCanvas {
    constructor (canvas) {
        this.ctx = canvas.getContext('2d');
        this.width = this.ctx.canvas.width;
        this.height = this.ctx.canvas.height;
        this.imgStack = [];
    }
    drawImage (...params) {
        const imgData = this.ctx.getImageData(0, 0, this.width, this.height);
        this.imgStack.push(imgData);
        this.ctx.drawImage(...params);
    }
    undo () {
        if (this.imgStack.length > 0) {
            const imgData = this.imgStack.pop();
            this.ctx.putImageData(imgData, 0, 0);
        }
    }
}

咱們封裝了一下 canvasdrawImage 方法,每次調用該方法以前都會保存上一個狀態的快照到模擬的棧中。在執行 undo 操做時,從棧中取出最新保存的快照,而後從新繪製畫布,便可實現撤銷操做。實際測試也符合預期。

性能優化

上一節中咱們很粗獷地實現了 canvas 的撤銷功能。爲何說粗獷呢?一個很顯而易見的緣由就是此方案性能很差。咱們的方案至關於每次都是從新繪製整個畫布。假設操做步驟不少,咱們在模擬棧也就是內存中就會保存不少預存的圖片數據。此外,在繪製圖片過於複雜時,getImageDataputImageData 這兩個方法會產生比較嚴重的性能問題。stackoverflow 上有詳細的討論: Why is putImageData so slow?。咱們還能夠從 jsperf 上這個測試用例的數據來驗證這一點。淘寶 FED 在 Canvas 最佳實踐中也提到了儘可能「不在動畫中使用putImageData 方法」。另外,文章裏還提到一點,「儘量調用那些渲染開銷較低的 API」。咱們能夠從這裏入手思考如何進行優化。

以前說過,咱們經過對整個畫布保存快照的方式來記錄每一個操做,換個角度思考,若是咱們把每次繪製的動做保存到一個數組中,在每次執行撤銷操做時,首先清空畫布,而後重繪這個繪圖動做數組,也能夠實現撤銷操做的功能。可行性方面,首先這樣能夠減小保存到內存的數據量,其次還避免了使用渲染開銷較高的 putImageData。以 drawImage 爲比較對象,看 jsperf 上這個測試用例,兩者的性能存在數量級的差距。

圖片描述

所以,咱們認爲此優化方案是可行的。

改進後的應用方式大體以下:

class WrappedCanvas {
    constructor (canvas) {
        this.ctx = canvas.getContext('2d');
        this.width = this.ctx.canvas.width;
        this.height = this.ctx.canvas.height;
        this.executionArray = [];
    }
    drawImage (...params) {
        this.executionArray.push({
            method: 'drawImage',
            params: params
        });
        this.ctx.drawImage(...params);
    }
    clearCanvas () {
        this.ctx.clearRect(0, 0, this.width, this.height);
    }
    undo () {
        if (this.executionArray.length > 0) {
            // 清空畫布
            this.clearCanvas();
            // 刪除當前操做
            this.executionArray.pop();
            // 逐個執行繪圖動做進行重繪
            for (let exe of this.executionArray) {
                this.ctx[exe.method](...exe.params)
            }
        }
    }
}

新人入坑 canvas,若有錯誤與不足,歡迎指出。

參考文獻

小tips:使用canvas在前端實現圖片水印合成
Canvas 最佳實踐(性能篇)
Canvas - Web API 接口 | MDN
相關文章
相關標籤/搜索