前端高效能動畫最佳實踐

積木村の研究所發表於2016-05-03

何為高效能動畫?讓人感覺流程順滑即可。24fps的電影就能讓人感覺到流暢,但是遊戲卻要60fps以上才能讓人感覺到流暢。分析原因,我們得出如下結論:

(1)視訊的每一幀記錄的是一段時間段(1/24s)的資訊,而遊戲的每一幀都由顯示卡繪製,它只能生成一個時間點的資訊;
(2)視訊的幀率是穩定的,而在系統負載不平穩時,顯示卡很難保證遊戲幀率的穩定性;

前端動畫與遊戲的原理類似,我們設計高效能動畫的基本思路就是提高幀率穩定幀率。讓我們首先一起了解一下瀏覽器渲染頁面的基本過程。

1.理解瀏覽器渲染流水線

渲染的基本流程是:掃描HTML文件結構、計算對應的CSS樣式並生成RenderTree,然後根據RenderTree進行佈局和繪製,基本過程示意圖如下:

webkit-flow

為了更簡單的分析和定位渲染效能問題,我們將渲染流程抽象為五大步驟:

render-flow

(1).Recalculate Style: 流水線中的第一步通常是使用javascript計算出需要如何操作DOM結構、並計算節點的最終樣式規則

(2).layout:第二步通常是根據節點的css規則,來計算節點在螢幕上位置和尺寸。由於頁面是按著文件流從上到下、從左到有佈局地,一個節點的佈局發生變化,可能使得多個節點重新佈局

(3).update layer tree:一個頁面可能有多個渲染層,layer tree用來維護各個渲染層的順序

(4).paint:繪製本質上就是填充畫素的過程。包括繪製文字、顏色、影像、邊框和陰影等,由此確定一個DOM元素所有的可視效果。繪製一般是在多個層(layer)上同時進行。

(5).composite: 在多個層上分別完成繪製後,瀏覽器會按各個繪製層的正確順序(layer tree中維持了各個圖層的順序)拼合成一個圖層,最終顯示在螢幕上。

理論上每一幀都要經過渲染流水線的處理,但渲染流水線中的有些步驟是可以跳過的。我們只修改節點的不影響佈局的屬性(背景圖片、陰影等)時,就不需要重新layout了:

render-flow-exlucde-layout

如果修改不觸發繪製(直接在GPU中完成)的樣式,比如transform、opacity等,甚至連paint都不需要了:

render-flow-exlucde-layout-and-paint

2.監控動畫效能

我們必須首先學會如何對動畫的效能指標(幀率數、幀率穩定性)進行監控,才能有針對性的提高動畫的效能。chrome開發者工具中的timeline是絕佳的工具,我們可以檢視每一幀都經過渲染流水線的哪些步驟:

chrome-tiimeline

上圖中,我選中了其中一幀,可以從最底部的Event Log中看到這一幀沒有經過渲染流水線中的layout和paint階段。

(2) 通過時間戳計算幀率

chrome開發者工具中的timeline最大的問題就是其本身比較消耗資源,在開啟timeline後,動畫的幀率下降明顯,因此其資料可能無法反映動畫的正常執行情況。如果只是需要統計幀率,可以通過記錄繪製每個幀消耗的時間來計算,第三方庫stats.js幫我們做了這些事情。下面是一個視覺化的例子:

JS Bin on jsbin.com

3.提高動畫效能指標

上文提到過,動畫的效能指標有兩個,幀率數和幀率穩定性。我們分別從動畫實現,節點的處理,屬性的選擇等方面討論如何提高這兩個動畫效能指標。

(1).選擇穩定的實現方式

css3動畫使用起來非常簡單,目前的瀏覽器支援率也不錯,足以應對一般的互動需求,我們應該優先使用它。當瀏覽器不支援css3時,或動畫場景過於複雜而僅憑css3無能為力時,就需要引入js來幫忙了。我們最常想到的js動畫的實現方式,就是固定時間間隔修改元素的樣式:

setInterval(function(){
    var anmationNode = document.getElementById('animation-node'); 
    //定期修改節點的樣式
}, 100)

但這是一種非常粗暴的方式,其弱點是很明顯的。瀏覽器的timer的觸發時間點是不固定的,如果遇到比較長的同步任務,其觸發時間點就會推遲,顯然也就保證不了動畫幀率的平穩性。HTML5為建立逐幀動畫提供了一個新的API:RequestAnimationFrame,該方法在每次瀏覽器渲染時觸發,其觸發頻率為60fps,我們可以通過這個函式來實現動畫,而當動畫中某些幀計算量太大無法在1/60s完成時,瀏覽器會將重新整理評論降低到30fps,以保證幀率的穩定性。

function step(){
    //修改節點樣式
    RequestAnimationFrame(step);
}
RequestAnimationFrame(step);

但是由於RequestAnimationFrame支援程度還不高(手機瀏覽器普遍不支援),我們可以結合RequestAnimationFramesetInterval實現一套逐漸增強和優雅降級的方案,下面是相容各個瀏覽器的終極版本:

function getAnimationFrame() {
    if (window.requestAnimationFrame) { //較新瀏覽器
        return {
            request: requestAnimationFrame,
            cancel: cancelAnimationFrame,
        }
    } else if (window.mozRequestAnimationFrame && window.mozCancelAnimationFrame) { //firfox瀏覽器
        return {
            request: mozRequestAnimationFrame,
            cancel: mozCancelAnimationFrame
        }
    } else if (window.webkitRequestAnimationFrame && webkitRequestAnimationFrame(String)) {
        return {
            request: function(callback) {
                return: window.webkitRequestAnimationFrame(function() {
                    return callback(new Date - 0); //修正部分webkit版本下沒有給callback傳time引數的bug
                });
            },
            cancel: window.webkitCancelAnimationFrame || window.webkitCancelRequestAnimationFrame
        }
    } else { //用setInterval模擬requestAnimationFrame
        var millisec = 25; //40fps;
        var callbacks = [];
        var id = 0,
            cursor = 0;
        var timerId = null;

        function playAll() {
            var cloned = callbacks.slice(0);
            cursor += callbacks.length;
            callbacks.length = 0;
            var hits = 0;
            for (var i = 0, callback; callback = cloned[i++];) {
                if (callback !== 'cancelled') {
                    callback(new Data - 0);
                    hits++;
                }
            };
            if (hits == cloned.length) {
                clearInterval(timerId);
            }
        }

        timerId = window.setInterval(playAll, millisec);
        return {
            request: function(handler) {
                callbacks.push(handler);
                return id++;
            },
            cancel: function() {
                callbacks[id - cursor] = 'cancelled';
            }
        }
    }
}

(2).為動畫節點建立新的渲染層

通過將動畫節點與文件中的其他節點隔離開來,可以有效的減少重新佈局(relayout)和重新繪製(repaint)的面積,從而提高頁面的整體效能。隔離動畫節點與文件中的其他節點方法通常是為動畫節點建立新的渲染層(render layer)。下面是建立渲染層的常用方法:

<1> 使用3D變換

大家一定經常看到網上的文章說使用transform: translate3d(0, 0, 0)/translateZ(0)可以開啟GPU加速,親自試驗以後發現其的確可以提高頁面的渲染速度,我就曾經用它解決了一些低端機的閃爍問題。 那麼其原理是什麼呢?這種方式並非一定能夠開啟GPU加速。

W3C標準是這麼說的。

Three-dimensional transforms can result in transformation matrices with a non-zero Z component (where the Z axis projects out of the plane of the screen). This can result in an element rendering on a different plane than that of its containing block. This may affect the front-to-back rendering order of that element relative to other elements, as well as causing it to intersect with other elements.

其主要意思就是3D變換會建立新的渲染層,而不是與其父節點在同一個渲染層中。在新的渲染層中修改節點不會干擾到其他節點,防止了對其他節點的重新佈局(relayout)和重新繪製(repaint),自然也就加快了頁面的渲染速度。除了transform: translate3d(0, 0, 0)/translateZ(0),我們還可以使用will-change

<2> 使用will-change

我們可以使用will-change讓瀏覽器提前瞭解預期的元素變換,它允許瀏覽器提前做好適當的優化,使之最後能夠快速和流暢的渲染。will-change: transform同樣也會為節點建立新的渲染層。

.animation-element{
     will-change: transform;
}

我們可以通過chrome的開發者工具中timeline的layers標籤,看到當前幀的渲染層。如下圖:

chrome-layer

上圖中右側有對建立layer原因的描述: has a will-change hint。但是管理渲染層是有成本的,過多的渲染層可能會降低頁面的渲染速度,因此我們應該避免濫用渲染層。

(3).選擇高效的動畫屬性

修改節點的大部分屬性都會引起重新繪製,甚至是重新佈局。而理想情況下,我們應避免重新繪製和重新佈局。幸運的當僅僅修改transfrom屬性或opacity屬性,可以做到不重新繪製。具體的思路是:為需要建立動畫的節點建立新的渲染層,並且在新渲染層中只修改transformopacity屬性。只有做到以上兩點才可以避免重新佈局和重新繪製,真正使用GPU加速。

(4).避免引起多餘的渲染

我們在實現動畫的過程中,經常需要獲取某個元素的屬性,然後對該屬性做出修改:

function step(){
    var animationNode = doucment.getElementById('animation-node');
    for(var i = 1; i <= 20 ; i++){
        animationNode.width = animationNode.width + 1;
    }
}

上述的for迴圈語句將導致瀏覽器進行20次多餘的渲染,嚴重影響頁面效能。通常來講JS對頁面樣式的多次修改只會在頁面下次重新整理時渲染一次,而通過DOM API獲取樣式時,會強制頁面完成一次渲染以體現最新修改後的值。上述例子就是這樣導致瀏覽器多次渲染的。而正確的寫法應該是讀寫分離。

var animationNode = doucment.getElementById('animation-node');
var initialWidth = animationNode.style.width;
for(var i = 1; i <= 20 ; i++){
    initialWidth+=1;
}
animationNode.style.width = initialWidth; 

當我們在複雜頁面上實現動畫是,常常由於疏忽導致頁面多餘的渲染。這是我們可以藉助fastdom來隔離對真實DOM的操作,fastdom將對節點樣式的讀寫批量快取、一次執行,防止多餘的渲染。

本文同時發表在我的部落格積木村の研究所http://foio.github.io/animation-performace/


參考資料

http://taligarsiel.com/Projects/howbrowserswork1.htm

http://matrix.h5jun.com/slide/show?id=117#/

http://melonh.com/sharing/slides.html?file=high_performance_animation#/

http://www.infoq.com/cn/articles/javascript-high-performance-animation-and-page-rendering

https://developers.google.com/web/fundamentals/performance/?hl=zh-cn

https://github.com/wilsonpage/fastdom

https://github.com/mrdoob/stats.js

相關文章