小程序和H5中canvas卡頓的性能優(yōu)化方向和實(shí)踐(小程序canvas使用)
什么是canvas? 首先介紹下canvas, 前端的同學(xué)可能很熟悉,舉個(gè)很簡單的例子,
平常用的網(wǎng)頁截圖、H5游戲、前端動(dòng)效、可視化圖表…,都有canvas 的應(yīng)用場景, 官方的定義:
canvas是HTML5提供的一種新標(biāo)簽,
ie9才開始支持的,canvas是一個(gè)矩形區(qū)域的畫布,可以用JS控制每一個(gè)像素在上面繪畫。canvas 標(biāo)簽使用 JavaScript
在網(wǎng)頁上繪制圖像,本身不具備繪圖功能。canvas 擁有多種繪制路徑、矩形、圓形、字符以及添加圖像的方法。
看著很簡單,其實(shí)canvas這個(gè)標(biāo)簽的加入,賦予了我們更多創(chuàng)建驚艷的前端效果的能力。但是你知道他也有性能問題??本篇文章就簡單談一談Canvas的性能優(yōu)化。
哪些因素會(huì)影響canvas的性能
canvas優(yōu)化的幾種方式
我們都知道瀏覽器上渲染動(dòng)畫 每一秒高達(dá)60幀,也就是1秒鐘內(nèi)我們完成60次圖像繪制, 也就是每一幀圖像的繪制時(shí)間其實(shí)就是(1000/ 60)。 如果在每一幀動(dòng)畫的時(shí)間小于 16.7 ms 辣么就會(huì)出現(xiàn)卡頓、丟幀。而canvas 其實(shí)是一個(gè)指令式繪圖系統(tǒng), 他通過繪圖指令來完成繪圖操作。
影響canvas兩個(gè)很關(guān)鍵的因素:
第一個(gè)渲染的圖形數(shù)量多,就是調(diào)用繪圖指令的次數(shù)比較多,
第二個(gè)渲染的圖形大,就是一次繪圖渲染的時(shí)間比較長
優(yōu)化canvas
1. 減少繪圖指令的調(diào)用
這句話怎么理解呢 , 假設(shè)你要在場景中畫正n變形,這是一個(gè) 很常見的需求可能你稍不注意寫下了下面這幾行代碼:
function drawAnyShape(points) { for(let i=0; i<points.length; i ) { const p1 = points[i] const p2 = i=== points.length - 1 ? points[0] : points[i 1] ctx.fillStyle = 'black' ctx.beginPath(); ctx.moveTo(...p1) ctx.lineTo(...p2) ctx.closePath(); ctx.stroke() } }
points 對(duì)應(yīng)的生成多邊形的點(diǎn),代碼如下:
function generatePolygon(x,y,r, edges = 3) { const points = [] const detla = 2* Math.PI / edges; for(let i= 0;i<edges;i ) { const theta = i * detla; points.push([x r * Math.sin(theta), y r * Math.cos(theta)]) } return points }
?
一看這fps低成這個(gè)樣子,很多人這時(shí)候說,你畫的圖形多,那我只要悄悄的改下代碼,就能讓fps 回歸正常
重寫了正多邊形的方法:
function drawAnyShape2(points) { ctx.beginPath(); ctx.moveTo(...points[0]); ctx.fillStyle = 'black' for(let i=1; i<points.length; i ) { ctx.lineTo(...points[i]) } ctx.closePath(); ctx.stroke() }
看了下fps 已經(jīng)成功升到了30fps, 這是為什么呢, 第一段我們?cè)谘h(huán)中去做繪圖操作, 循環(huán)一次, stoke() 一次,這顯然是不合理的,第二個(gè)直接把stoke() ,放到循環(huán)外,其實(shí)就調(diào)用了一次,所以我們可以得出減少繪圖指令是可以提高canvas的性能的
2.分層渲染
為什么需要分層渲染, 在游戲中,假設(shè)人物的不停地在移動(dòng),但是呢背景可能加了很多花里呼哨的元素,但是我在每一次更新的時(shí)候,場景本身是不變的,變的只有人物不停的移動(dòng),如果每一幀再去重繪不就造成了性能浪費(fèi), 這時(shí)候分層canvas就出現(xiàn)了 我們先看下一張圖你可能就明白了。
我通過3個(gè)canvas疊在一起,通過設(shè)置每個(gè)canvas的 z-index 達(dá)到了3個(gè)畫布還是在同一層的錯(cuò)覺,這樣我在requestAnimation中,只需要對(duì) 動(dòng)的圖形去做重新繪制就好了,其余的依舊是保持不動(dòng) 。
偽代碼
<canvas id="backgroundCanvas" /><canvas id="peopleActionCanvas" />const peopleActionCanvas = document.getElementById('peopleActionCanvas');const backgroundCanvas = document.getElementById('backgroundCanvas');?function draw(){ drawPeopleAction(peopleActionCanvas); if (needDrawBackground) { drawBackground(backgroundCanvas); } requestAnimationFrame(draw);}
一個(gè)背景層一個(gè)運(yùn)動(dòng)層, 在抽象一點(diǎn),我們什么時(shí)候應(yīng)該去做分層 ,如果畫布純是靜態(tài)的就沒有必要去做分層了, 如果當(dāng)前有靜態(tài)有東動(dòng)態(tài)的,你可以邏輯層放在最上面,然后展示層 放在最底下就可以實(shí)現(xiàn)所謂的 分層渲染了,但是最好保持在3-5個(gè)。
3. 局部渲染
局部渲染的話其實(shí)就是調(diào)用canvas 的 clip方法。官方文檔MDN 對(duì)這個(gè)方法的使用
CanvasRenderingContext2D.clip() 是 Canvas 2D API 將當(dāng)前創(chuàng)建的路徑設(shè)置為當(dāng)前剪切路徑的方法
如何用canvas 畫一個(gè)1/4圓。
const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');ctx.fillStyle = 'red'ctx.arc(100, 100, 75, 0, Math.PI*2, false);//ctx.clip();ctx.fillRect(0, 0, 100,100);
這里填充的時(shí)候 沒有用clip 畫面上應(yīng)該是一個(gè)矩形。
這時(shí)候我把clip注釋解開來, 矩形變成了一個(gè)半圓。 所以clip 這個(gè) api 結(jié)合 fillRect 填充 就是實(shí)現(xiàn)填充任意圖形路徑。
canvas 中畫了1000 個(gè)圓形, 如果你只改一個(gè)顏色,那其他999都是不變的 這種浪費(fèi)是肯定存在性能問題, 如果在做動(dòng)畫效果可想而知,丟幀非常厲害。 這里就可以使用我們上面的api
正確的做法其實(shí)就是我們要做局部刷新:
確定改變的元素的包圍盒(是否存在相交)
畫出路徑 然后 clip
最后重新繪制繪制改變的圖形
clip() 確定繪制的的裁剪區(qū)域,區(qū)域之外的圖形不能繪制,詳情查看 CanvasRenderingContext2D.clip() clearRect(x, y, width, height) 擦除指定矩形內(nèi)的顏色,查看 CanvasRenderingContext2D.clearRect()
包圍盒
用一個(gè)框去把圖形包圍住, 其實(shí)在幾何中我們叫包圍盒 或者是boundingBox。 可以用來快速檢測兩個(gè)圖形是否相交, 但是還是不夠準(zhǔn)確。最好還是用圖形算法去解決。 或者游戲中的碰撞檢測,都有這個(gè)概念。這里討論的是2d的boudingbox, 還是比較簡單的。
虛線框其實(shí)就是boundingBox, 其實(shí)就是根據(jù)圖形的大小,算出一個(gè)矩形邊框。理論我們知道了,映射到代碼層次, 我們?cè)趺慈ケ磉_(dá)呢? 這里帶大家原生實(shí)現(xiàn)一下bound2d 類, 其實(shí)每個(gè)2d圖形,都可以去實(shí)現(xiàn)。 因?yàn)?d圖形都是由點(diǎn)組成的,所以只要獲得每一個(gè)圖形的離散點(diǎn)集合, 然后對(duì)這些點(diǎn),去獲得一個(gè)2d空間的boundBox。
4.離屏CANVAS 和WEBWORKER
我們先說下 什么是離屏canvas???
OffscreenCanvas提供了一個(gè)可以脫離屏幕渲染的canvas對(duì)象。它在窗口環(huán)境和web worker環(huán)境均有效。
脫離屏幕渲染的canvas對(duì)象,這對(duì)我們實(shí)際寫動(dòng)畫的時(shí)候真的有用嗎???
想象以下這個(gè)場景:如果發(fā)現(xiàn)自己在每個(gè)動(dòng)畫幀上重復(fù)了一些相同的繪制操作,請(qǐng)考慮將其分流到屏幕外的畫布上。 然后,您可以根據(jù)需要頻繁地將屏幕外圖像渲染到主畫布上,而不必首先重復(fù)生成該圖像的步驟。由于瀏覽器是單線程,canvas的計(jì)算和渲染其實(shí)是在同一個(gè)線程的。這就會(huì)導(dǎo)致在動(dòng)畫中(有時(shí)候很耗時(shí))的計(jì)算操作將會(huì)導(dǎo)致App卡頓,降低用戶體驗(yàn)。
幸運(yùn)的是, OffscreenCanvas 離屏Canvas可以非常棒的解決這個(gè)麻煩!
到目前為止,canvas的繪制功能都與標(biāo)簽綁定在一起,這意味著canvas API和DOM是耦合的。而OffscreenCanvas,正如它的名字一樣,通過將Canvas移出屏幕來解耦了DOM和canvas API。
由于這種解耦,OffscreenCanvas的渲染與DOM完全分離了開來,并且比普通canvas速度提升了一些,而這只是因?yàn)閮烧撸–anvas和DOM)之間沒有同步。但更重要的是,將兩者分離后,canvas將可以在Web Worker中使用,即使在Web Worker中沒有DOM。這給canvas提供了更多的可能性。
這就離屏canvas 為啥和webworker 這么配的緣故了。
如何創(chuàng)建離屏CANVAS?
創(chuàng)建離屏canvas有兩種方式:
一種是通過OffscreenCanvas的構(gòu)造函數(shù)直接創(chuàng)建。比如下面的示例代碼:
// 離屏canvas const offscreen = new OffscreenCanvas(200, 200);第二種是使用canvas的transferControlToOffscreen函數(shù)獲取一個(gè)OffscreenCanvas對(duì)象,繪制該OffscreenCanvas對(duì)象,同時(shí)會(huì)繪制canvas對(duì)象。比如如下代碼: const canvas = document.getElementById('canvas');const offscreen = canvas.transferControlToOffscreen();我寫了下面這個(gè)小demo 驗(yàn)證下到底是不是可靠的 const canvas = document.getElementById('canvas'); // 離屏canvas const offscreen1 = new OffscreenCanvas(200, 200); const offscreen2 = canvas.transferControlToOffscreen(); console.error(offscreen1,offscreen2, '222')
離屏canvas怎么與主線程的canvas通信呢?
這時(shí)候引用另外一個(gè)api transferToImageBitmap
通過transferToImageBitmap函數(shù)可以從OffscreenCanvas對(duì)象的繪制內(nèi)容創(chuàng)建一個(gè)ImageBitmap對(duì)象。該對(duì)象可以用于到其他canvas的繪制。
比如一個(gè)常見的使用是,把一個(gè)比較耗費(fèi)時(shí)間的繪制放到web worker下的OffscreenCanvas對(duì)象上進(jìn)行,繪制完成后,創(chuàng)建一個(gè)ImageBitmap對(duì)象,并把該對(duì)象傳遞給頁面端,在頁面端繪制ImageBitmap對(duì)象。
寫個(gè)小demo測試下:
優(yōu)化前
我們畫 10000 * 10000 個(gè)矩形看看頁面的響應(yīng)和時(shí)間,代碼如下:
const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); function draw() { for(let i = 0;i < 10000;i ){ for(let j = 0;j < 1000;j ){ ctx.fillRect(i*3,j*3,2,2); } } } draw() ctx.arc(100,75,50,0,2*Math.PI); ctx.stroke()
可以很明顯的感受到,在渲染出圖形前,瀏覽器是失去響應(yīng)的,我們無法做認(rèn)可操作。這樣的用戶體驗(yàn)肯定是非常差的。
優(yōu)化后
我們使用離屏canvas webworker 進(jìn)行優(yōu)化,代碼如下:
我們先看下worker 的代碼:
let offscreen,ctx;// 監(jiān)聽主線程發(fā)的信息onmessage = function (e) { if(e.data.msg == 'init'){ init(); draw(); }} function init() { offscreen = new OffscreenCanvas(512, 512); ctx = offscreen.getContext("2d");}// 繪制圖形function draw() { ctx.clearRect(0,0,offscreen.width,offscreen.height); for(var i = 0;i < 10000;i ){ for(var j = 0;j < 1000;j ){ ctx.fillRect(i*3,j*3,2,2); } } const imageBitmap = offscreen.transferToImageBitmap(); // 傳送給主線程 postMessage({imageBitmap:imageBitmap},[imageBitmap]);}
看下主線程的代碼:
const worker = new Worker('./worker.js')worker.postMessage({msg:'init'});worker.onmessage = function (e) { // 這里就接受到work 傳來的離屏canvas位圖 ctx.drawImage(e.data.imageBitmap,0,0);} ctx.arc(100,75,50,0,2*Math.PI); ctx.stroke()
對(duì)比兩個(gè)很明顯的變化, 畫多個(gè)矩形是個(gè)非常耗時(shí)的操作會(huì)影響其他圖形渲染,可以采用離屏canvas webworker 來解決這種失去響應(yīng)。
5.禁用頁面和canvas的滾動(dòng)事件
touchmove事件和滾動(dòng)事件有時(shí)候是有沖突的,這樣在我們移動(dòng)手指時(shí)回導(dǎo)致繪畫效果的卡頓,或者事件點(diǎn)位跳躍的情況發(fā)生,這時(shí)候我們只需要把滾動(dòng)事件禁用既可以了
禁用方式是在標(biāo)簽上加上或者微信小程序頁面加上"disableScroll": true,如果是uniapp在pages.json加上
“disableScroll”: true
<canvas :id="cid" disable-scroll="true" type="2d" ></canvas>
總結(jié)
- 繪制的圖形的數(shù)量和大小會(huì)影響canvas的性能,減少繪圖次數(shù),減少canvas接口調(diào)用次數(shù)
- 圖形數(shù)量過多,但是只刷新部分 可以使用局部渲染
- 邏輯層和背景圖層分離 可以使用分層渲染
- 某些長時(shí)間的邏輯影響主線程的, 可以使用離屏渲染 和webworker 來解決問題
- 禁用頁面和容器的滾動(dòng)