探究 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 所有状态的方法。markdown

乍看起来能够知足需求。咱们看一下官方示例代码: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,也就是绘制状态。保存到栈中的绘制状态包含如下几个部分:oop

  • 当前的变换矩阵
  • 当前的剪切区域
  • 当前的虚线列表
  • 如下属性当前的值: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

相关文章
相关标签/搜索