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

逆葵發表於2018-04-29

最近在做網頁版圖片處理相關的專案,也算是初入了 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 畫布上就行。

以上就是背景介紹。但是略麻煩的是新增水印的需求中還有一個需要實現的功能是使用者能夠切換水印的位置。我們自然會想到能否實現 canvasundo 功能,當使用者切換水印位置時,先撤銷上一步 drawImage 操作,然後再重新繪製水印圖片位置。

restore/save ?

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

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

CanvasRenderingContext2D.save() 是 Canvas 2D API 通過將當前狀態放入棧中,儲存 canvas 全部狀態的方法。

乍看起來可以滿足需求。我們看一下官方示例程式碼:

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);
複製程式碼

結果如下圖所示:

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

奇怪,好像和我們預期的結果不太一致。我們想要的結果是 save 方法呼叫後能夠儲存當前畫布的快照,resolve 方法呼叫後能夠完全回到上一個儲存的快照處的狀態。

再仔細研究一下 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 上這個測試用例,二者的效能存在數量級的差距。

探究 canvas 繪圖中撤銷(undo)功能的實現方式
因此,我們認為此優化方案是可行的。

改進後的應用方式大致如下:

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

相關文章