Canvas性能优化小结

简介

H5中引入了对canvas的支持,使得网页的表达能力更加丰富了。程序员可以通过canvas来绘制复杂的图形,甚至是游戏。因为工作中的需求,需要使用canvas在网页上绘制家谱。具体的算法可以看之前的一篇总结:树的可视化以及家谱绘制的算法
。当家谱中的数据量变大之后,整个绘制过程会变的很卡(800左右人物的家谱1000次绘制居然需要25s左右)。但是在做完优化后,1000次绘制只需要0.15s左右。

家谱示例

目前的家谱绘制共有两部分。一个是家谱的主体,用户可以通过鼠标、滚轮去任意的浏览;一个是左上角的小地图功能,帮助用户了解到当前浏览的位置,小地图中的有色方块就是用户整个屏幕显示的内容。

优化措施一:缓存

缓存是在绝大多数系统中经常用到的提升性能的方法,典型的以空间换时间。在canvas中当然也可以使用缓存。在家谱的每一次绘制中,都要遍历所有的人物,然后计算他们的位置大小,然后绘制到界面上,然后还要遍历所有的路径再次进行绘制。而使用缓存的目的就是避免这一部分的计算。因此,在任何复杂计算后的绘制,都能使用缓存来提升性能。

在canvas中有这样一个方法,CanvasRenderingContext2D.drawImage(image, dx, dy)image参数代表要绘制的图片源,不仅仅可以是一个image对象,还可以是一个canvas对象。所以如果我们将一个复杂的图形作为canvas对象缓存起来,然后直接使用drawImage方法去绘制到画面上,就能减少很多的重复计算,来提升性能。

例如以下的伪代码

function paintPerson() {
    // paint 
}

function paintFamilyTree() {
    for(var i = 0; i < 10000; i++) {
        paintPerson()
    }
}

// 用户移动鼠标就需要重绘族谱
dom.addEventListener("mousemove", function() {
    paintFamilyTree()
}

用户每一次移动鼠标,都需要10000次的循环去画。所以我们使用下面的方法,使得只需要计算一次

function paintPerson() {
    // paint 
}

function paintFamilyTree() {
    for(var i = 0; i < 10000; i++) {
        paintPerson()
    }
}

var canvasObj = cache(paintFamilyTree) // 缓存这次绘制的结果。

dom.addEventListener("mousemove", function() {
    ctx.drawImage(canvasObj,0,0)
}

那么如何去缓存这个 canvas 对象呢?可以使用document.createElement('canvas')来创建一个不在页面上显示的 canvas 对象,一般也称它为离屏画布,这里用变量 offScreenCanvas 来称呼。然后将绘制的图形画在这个 canvas 上,之后调用 ctx.drawImage(offScreenCanvas,0,0) 即可。

在创建这个离屏画布的时候,我们也要设置合适的宽高,这样也有助于提升性能。

可以参考这个例子来感受一下性能差距:
离屏画布缓存来提升页面性能
例子代码如下:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>离屏canvas实例</title>
        <style>
            .main-wrapper {
                width: 800px;
                margin: 10px auto;
            }
        </style>
    </head>

    <body onload="init()">
        <div class="main-wrapper">
            <canvas width="800" height="600" style="border: 1px solid #ccc" id="canvas">
                你的浏览器不支持canvas
            </canvas>
            <br><br><br><br><br><br><br><br><br>
            <div id="result"></div>
            <br>
            渲染次数:<input type="number" id="times" value="1000">,共耗时(ms)<span id="time_used">0</span><br>
            <button onclick="doTest(false,false)">不使用离屏canvas</button>
            <button onclick="doTest(true,false)">使用离屏canvas</button>
            <button onclick="doTest(true,true)">使用离屏canvas并设置正确的高度</button>
        </div>
        <script>
            /**
             * 离屏缓存,num为缓存canvas的数量
             */
            var OffScreenCache = function (num) {
                this.canvases = [];
                for (i = 0; i < num; i++) {
                    this.canvases.push(document.createElement("canvas"));
                }
            }
            OffScreenCache.prototype = {
                pop: function() {
                    return this.canvases.pop();
                },
                push: function(canvas) {
                    this.canvases.push(canvas);
                },
                destroy: function() {
                    this.canvases = null;
                }
            }
            var Ball = function (color) {
                this.radius = 50;
                this.lineWidth = 4;
                this.cache = null;
                this.color = color;
            }
            Ball.prototype = {
                paint: function(ctx, x, y) {
                    ctx.save();
                    ctx.lineWidth = this.lineWidth;
                    ctx.strokeStyle = this.color;
                    for (i = 1; i < this.radius; i+= this.lineWidth) {
                        ctx.beginPath();
                        ctx.arc(x, y, i, 0, Math.PI*2,true); // 绘制
                        ctx.stroke();
                    }
                    ctx.restore();
                },
                useCache: function (cacheCanvas, autoSet) {
                    if (autoSet) {
                        cacheCanvas.width = this.radius * 2;
                        cacheCanvas.height = this.radius * 2;
                    }
                    cacheCtx = cacheCanvas.getContext('2d')
                    this.paint(cacheCtx, cacheCanvas.width / 2, cacheCanvas.height / 2)
                    this.cache = cacheCanvas
                }
            }
            /******************** 下面是执行代码 **************************/
            var g_canvas, g_ctx;
            var g_width = 800;
            var g_height = 600;
            function init() {
                g_canvas = document.getElementById("canvas");
                g_ctx = g_canvas.getContext('2d');
            }
            function getRandomPos() {
                var x = Math.random() * g_width
                var y = Math.random() * g_height
                return {x:x, y:y}
            }
            function showResult(ballCount) {
                var dom = document.getElementById("result");
                dom.innerHTML = "";
                var str = "";
                for (var key of Object.keys(ballCount)) {
                    str += "<span>" + key + "ball:" + ballCount[key] + "</span>    "
                }
                dom.innerHTML = str;
            }
            function doTest(useCache, autoSet) {
                g_ctx.clearRect(0, 0, g_width, g_height)
                var startTime = Date.now();
                var times = document.getElementById("times").value
                var colorArray = ['red', 'blue', 'green', 'black', 'pink']   // 共绘制5种颜色
                var ballCount = {};
                colorArray.forEach(function(color) {
                    ballCount[color] = 0;
                })
                if (useCache) {
                    var colorBall = [];
                    var cacheCanvases = new OffScreenCache(colorArray.length);
                    for (var i = 0; i < colorArray.length; i++) {
                        var ball = new Ball(colorArray[i]);
                        var cacheCanvas = cacheCanvases.pop();
                        ball.useCache(cacheCanvas, autoSet)
                        colorBall.push(ball)
                    }
                    for (var i = 0; i < times; i++) {
                        ball = colorBall[i % 5]
                        ballCount[ball.color]++;
                        var pos = getRandomPos()
                        g_ctx.drawImage(ball.cache, pos.x, pos.y)       // 开始画
                    }
                } else {
                    for (var i = 0; i < times; i++) {
                        var color = colorArray[i % 5];
                        var ball = new Ball(color)
                        var pos = getRandomPos()
                        ball.paint(g_ctx, pos.x, pos.y)               // 开始画
                        ballCount[color]++;
                    }
                }
                var endTime = Date.now();
                document.getElementById("time_used").innerText = endTime - startTime;
                showResult(ballCount)
            }
        </script>
    </body>
</html>

优化措施二:分层

在上述的族谱图片中,左上角的小地图其实是有两个canvas重叠而成。底层的canvas绘制族谱的缩略图,上层的canvas绘制小方框。这样在小方框移动时,只需清空上层canvas的局部画布重绘小方框即可,不需要重绘底层的缩略图。

ubuntu上使用chrome进行手机页面调试的方案

虽然大多数时候我们都可以使用chrome的开发者工具,通过模拟手机来调试手机页面,但是对于一些特殊的动作是无法在电脑上做的,比如说手机的多点触控操作,在页面上模拟双指缩放时没法使用鼠标完成,因此需要通过手机来进行真机调试,但是调试的同时,我们希望可以看到调试信息,希望可以实时修改查看。这个时候就可以借助chrome的远程设备使用手机chrome打开页面,在电脑chrome中调试。具体的调试过程在
远程调试 Android 设备使用入门
可以看到。下面只是记录ubuntu下使用该方案遇到的问题。

一.打开远程调试界面

打开chrome的控制台,点击右上角的列表按钮,找到more tool->remote device即可。

二.找不到已连接的手机

这个在windows上一般比较容易,因为基本上各种安全软件都会自动帮助用户连上,在Ubuntu下我们需要使用adb来连接手机。使用adb start-server来开启服务监听手机的连接。如果出现权限错误,可以adb kill-server,再adb start-server。只要手机上弹出连接确认框,即说明已经连上了。

三.Inspect的页面空白

这个问题困扰了我很久,因为我在windows上是没有问题的,后来查了一下,是因为chrome需要翻墙才行,在windows上我使用的是shadowsocks的pac方案,linux上则是自己建立的翻墙规则,导致有遗漏。所以如果出现Inspect页面空白,可以尝试全局翻墙

四.如果在手机上访问开发机器上的服务

这个问题有这种解决方案。
1.如果手机和电脑在同一个局域网内,可以直接访问电脑的ip地址即可。但是http服务监听的地址不能只是localhost,应该是电脑的ip地址或者0.0.0.0
2.使用远程设备的端口转发功能,设置如下图
端口转发设置
成功的界面如下
端口转发设置成功
这样,我在手机上访问localhost:4200,相当于在电脑上访问localhost:4200,在手机上访问localhost:1025,相当于在电脑上访问localhost:80。需要注意的是,端口转发的左侧输入框端口号必须大于1024,这是因为小于等于1024的端口号是被划分出来特殊使用的。