本文是調研如何實現高性能動畫, 提升用户體驗的總結, 文章內容來源於對看過的相關技術文章的總結, 相關技術文章已列到文章末尾, 如有遺漏, 敬請諒解.

快速響應和高度交互的頁面往往能夠吸引大量的用户群體. 相反, 如果頁面存在性能低下的動畫, 動畫不流暢, 動畫過程中頁面閃爍等等, 如此粗糙的交互體驗必然喪失用户量.

對於大多數的設備而言, 屏幕以 60 次每秒的頻率刷新, 即60HZ. 如果一個動畫中的某些幀超過了這個時間, 就會導致瀏覽器的刷新頻率跟不上設備的刷新頻率(跳幀現象), 出現頁面閃爍. 因此, 高性能的動畫都應該保持在60fps左右.

接下來我們看幾種動畫的實現方式.

基於setTimeout或者setInterval實現的動畫

基於幀算法實現的動畫

很明顯產生的交互效果是不符合預期的. 導致這種情況的原因很簡單, 因為我們計算和繪製每個div位置的時候是在每幀更新, 每幀移動2px. 在60fps的情況下, 我們 1 秒鐘會執行60幀, 所以小塊每秒鐘會移動60 * 2 = 120px; 如果是30fps, 小塊每秒就移動30 * 2 = 60px, 以此類推10fps就是每秒移動20px. 三個小塊在單位時間內移動的距離不一樣.

基於時間算法實現的動畫

針對於這種情況, 我們對其作出改進. 我們不再以幀為基準來更新方塊的位置, 而是以時間為單位更新. 也就是説, 我們之前是px/frame, 現在換成px/ms.

在這裏, 我們先確定一個固定更新的時間片, 如固定為60fps時一幀的時間:1000 / 60 = 0.167ms. 然後積累過去的時間, 然後根據固定時間片分片進行更新. 也就説, 即使這一幀和上一幀相差過去了100ms, 我也會把這 100ms 分成很多個0.167ms來執行update函數. 這樣做有兩個好處:

  • 固定的時間片足夠小,更新的時候可以減少動畫失幀
  • 不同幀率, 不管你是60,30, 還是10fps, 也是根據固定時間片來執行 update 函數, 所以即使有損失, 不同幀率之間的損失是一樣的. 那麼我們三個方塊就可以達到同步移動的效果的了!

基於setTimeout或者setInterval實現動畫存在的問題

使用setTimeoutsetInterval來繪製動畫, 計算延時的精確度是不夠的.

延時的計算依靠的是瀏覽器的內置時鐘, 而時鐘的精確度又取決於時鐘更新的頻率. 不同版本的瀏覽器, 這個頻率是不一樣的: IE8 及其之前的 IE 版本更新間隔為 15.6 毫秒, 最新版的 Chrome 與 IE9 + 瀏覽器的更新頻率都為 4ms. 而且如果你使用的是筆記本電腦, 並且在使用電池而非電源的模式下, 為了節省資源, 瀏覽器會將更新頻率切換至於系統時間相同, 更新頻率更低.

而另外一個問題, 使用setTimeoutsetInterval, 需要面臨異步隊列問題. 因為異步關係,setTimeoutsetInterval中回調函數並非立即執行. 而是需要加入等待隊列中. 但問題是, 如果在等待延遲觸發的過程中, 有新的同步腳本需要執行, 那麼同步腳本不會排在回調之後, 而是立即執行.

例如:

很顯然, 這樣的動畫交互體驗是不可控的.

基於requestAnimationFrame實現的動畫

針對setTimeoutsetInterval實現動畫存在的缺陷,Mozilla首先推出了mozRequestAnimationFrame, 通過它告訴瀏覽器某些 JavaScript 代碼將要執行動畫, 這樣瀏覽器可以在運行某些代碼後進行適當的優化. 之後,ChromeIE10+也都給出了自己的實現,webkitRequestAnimationFramemsRequestAnimationFrame. 後來隨着HTML5新的 API 發佈,requestAnimationFrame被正式推出.

官方定義:

window.requestAnimationFrame() 這個方法是用來在頁面重繪之前, 通知瀏覽器調用一個指定的函數, 以滿足開發者操作動畫的需求. 這個方法接受一個函數為參, 該函數會在重繪前調用.

注意: 如果想得到連貫的逐幀動畫, 函數中必須重新調用 requestAnimationFrame().

requestAnimationFrame最大的好處在於可以可以避免瀏覽器不必要的重繪. 想要理解這個好處, 我們首先需要簡單瞭解一下瀏覽器的渲染過程.

瀏覽器渲染過程

要實現一個高性能的動畫, 首選我們必須對瀏覽器的渲染機制有所瞭解:

更加詳細的渲染過程解讀詳見瀏覽器的工作原理:新式網絡瀏覽器幕後揭祕

Chrome 渲染過程:
webkitflow.png

從圖中可以看出, 瀏覽在渲染頁面過程中依次經歷了:

  1. HTML Parse(html 解析)
  2. Calculate Style(計算樣式)
  3. Layout(佈局)
  4. Rasterizer(光柵化)
  5. Paint(繪製)
  6. Composite Layers(渲染層合併)

HTML Parser

發送http請求, 獲取請求內容, 然後解析 HTML 的過程. 更加詳細的可以看這裏 What happens when… 以及對應的翻譯文檔當 ··· 時發生了什麼?

Calculate Style

即計算樣式.

Calculate 被觸發的時候做的事情就是處理 JavaScript 給元素設置的樣式. 此時 Recalculate Style 會計算 Render 樹 (渲染樹), 然後從根節點開始進行頁面渲染, 將 CSS 附加到 DOM 上的過程.

這個過程是根據 CSS 選擇器, 對每個 DOM 元素匹配對應的 CSS 樣式. 這一步結束之後, 就確定了每個 DOM 元素上應該應用什麼 CSS 樣式規則.

任何企圖改變元素樣式的操作都會觸發 Recalculate(重新計算樣式). 同 Layout 一樣, 它們都是 JavaScript 執行完後才觸發的.

Layout

計算頁面上的佈局, 即元素在文檔中的位置及大小. 正如前面所述, Layout 計算的是佈局位置信息.

上一步確定了每個 DOM 元素的樣式規則, 這一步就是具體計算每個 DOM 元素最終在屏幕上顯示的大小和位置.

需要注意的是: 在頁面解析完成後, 任何有可能改變元素位置或大小的樣式都會觸發這個 Layout 事件.

常見影響佈局的 CSS 屬性有:

  • width
  • height
  • padding
  • margin
  • display
  • border-width
  • border
  • top
  • position
  • font-size
  • float
  • text-align
  • overflow-y
  • font-weight
  • overflow
  • left
  • font-family
  • line-height
  • vertical-align
  • right
  • clear
  • white-space
  • bottom
  • min-height

等等, 更多觸發 Layout 事件的屬性, 可以在 CSS Triggers 網站查閲.

Rasterizer

光柵化, 一般的安卓手機都會進行光柵化, 光柵主要是針對圖形的一個柵格化過程. 低端手機在這部分耗時還是蠻多的.

Paint

本質上就是填充像素的過程. 包括繪製文字、顏色、圖像、邊框和陰影等, 也就是一個 DOM 元素所有的可視效果. 一般來説, 這個繪製過程是在多個層上完成的.

Paint 的工作就是把文檔中用户可見的那一部分展現給用户. Paint 是把 Layout 和 Calculate 的計算的結果直接在瀏覽器視窗上繪製出來, 它並不實現具體的元素計算.

同樣, 頁面解析完成後, 改變某些樣式也會引起 RePaint(重繪).

常見引起 RePaint(重繪) 的樣式:

  • color
  • border-style
  • visibility
  • background
  • text-decoration
  • background-image
  • background-position
  • background-repeat
  • outline-color
  • outline
  • outline-style
  • border-radius
  • outline-width
  • box-shadow
  • background-size

如果你在元素中對以上的屬性設置動畫, 那麼將會引起重繪, 並且元素所屬的圖層將提交給 GPU 進行處理.
對於移動端設備來説, 這代價是非常昂貴的, 因為它們的 CPU 的處理能力明顯弱於桌面端. 這意味着, 任務將用更長的時間來完成; 並且 CPU 和 GPU 之間的帶寬是有限的, 所以數據的上傳需要花費很長的一段時間.

Composite Layers

最後合併圖層, 輸出頁面到屏幕. 瀏覽器在渲染過程中會將一些含有特殊樣式的 DOM 結構繪製於其他圖層, 有點類似於PhotoShop的圖層概念. 一張圖片在PotoShop是由多個圖層組合而成, 而瀏覽器最終顯示的頁面實際也是有多個圖層構成的.

在每個層上完成繪製過程之後, 瀏覽器會將所有層按照合理的順序合併成一個圖層, 然後在屏幕上呈現. 對於有位置重疊的元素的頁面, 這個過程尤其重要, 因為一旦圖層的合併順序出錯, 將會導致元素顯示異常.

常見的導致新圖層創建的因素有:

  • 進行 3D 或者透視變換的 CSS 屬性
  • 使用硬件加速視頻解碼的<video>元素
  • 具有 3D(WebGL) 上下文或者硬件加速的 2D 上下文的<canvas>元素
  • 組合型插件 (即 Flash)
  • 具有有 CSS 透明度動畫或者使用動畫式 Webkit 變換的元素
  • 具有硬件加速的 CSS 濾鏡的元素

影響動畫渲染性能的因素

上述流程可以歸納為五個關鍵步驟:

css-animation-4.jpg

這也是我們在實現動畫過程中有可能會觸發的五個步驟, 搞清楚我們實現動畫的代碼在哪一步, 有助於我們實現高性能流暢的動畫.
在上面的流程中, 我們需要注意兩個概念 重排 (也就是迴流)重繪. 這兩個概念與上述流程中的 Layout 和 Paint 都有關係, 而 Layout 和 Paint 又對動畫渲染的性能至關重要.

重排

Reflow(重排) 指的是計算頁面佈局 (Layout). 某個節點Reflow時會重新計算節點的尺寸和位置, 而且還有可能觸發其後代節點Reflow. 在這之後再次觸發一次Repaint(重繪).

當 Render Tree 中的一部分 (或全部) 因為元素的尺寸、佈局、隱藏等改變而需要重新構建. 這就稱為迴流, 每個頁面至少需要一次迴流, 就是頁面第一次加載的時候.

在 Web 頁面中, 很多狀況下會導致迴流:

  • 調整窗口大小
  • 改變字體
  • 增加或者移除樣式表
  • 內容變化
  • 激活 CSS 偽類
  • 操作 CSS 屬性
  • JavaScript 操作 DOM
  • 計算offsetWidthoffsetHeight
  • 設置style屬性的值
  • CSS3 Animation 或 Transition

重繪

Repaint(重繪) 或者Redraw遍歷所有節點, 檢測節點的可見性、顏色、輪廓等可見的樣式屬性, 然後根據檢測的結果更新頁面的響應部分.
當 Render Tree 中的一些元素需要更新屬性, 而這些屬性只是影響元素的外觀、風格、而不會影響佈局的. 就是重繪.

將重排和重繪的介紹結合起來, 不難發現: 重繪 (Repaint) 不一定會引起迴流(Reflow 重排), 但迴流必將引起重繪(Repaint).

由此可見, 重排和重繪很容易被觸發, 而他們對動畫渲染的性能影響非常大. 我們需要做的是儘量不去觸發重繪和重排.

動畫渲染性能優化

過早進行性能優化是大忌, 如果我們實現的動畫並沒有性能方面的問題, 就沒有必要將時間成本浪費在性能優化上.

Composite這步優化動畫

在實現用户交互動畫的過程中, 我們儘量避免重繪和重排. 現在瀏覽器可以利用transformopacity繪製很好的動畫. 因為這些屬性只會影響
瀏覽器渲染的最後一步Composite過程.

共有四個讓動畫更好的屬性:

  • 位置 (Position): transform: translateX(n) translateY(n) translateZ(n)
  • 縮放 (Scale): transform: scale(n)
  • 旋轉 (Rotation): transform: rotate(ndeg)
  • 透明度 (Opacity): opacity: n

在 GPU 上運行動畫

在 CSS 中提供了一個新的 CSS 特性:will-change. 其主要作用就是 提前告訴瀏覽器我這裏將會進行一些變動, 請分配資源 (告訴瀏覽器要分配資源給我). 因此瀏覽器不需要考慮容器佈局的渲染或繪製.

will-change屬性, 允許作者提前告知瀏覽器的默認樣式, 那他們可能會做出一個元素. 它允許對瀏覽器默認樣式的優化如何提前處理因素, 在動畫實際開始之前, 為準備動畫執行潛在昂貴的工作. 有關於will-change更詳細的介紹可以點擊這裏.

在使用will-change一定要注意方式方法, 比如常見的錯誤方法是直接在:hover是使用, 並沒有告訴瀏覽器分配資源:

1
2
3
4
5
.element:hover {
will-change: transform;
transition: transform 2s;
transform: rotate(30deg) scale(1.5);
}

其正確使用的方法是, 在進入父元素的時候就告訴瀏覽器, 你該分配一定的資源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.element {
transition: opacity .3s linear;
}

/* declare changes on the element when the mouse enters / hovers its ancestor */

.ancestor:hover .element {
will-change: opacity;
}

/* apply change when element is hovered */

.element:hover {
opacity: .5;
}

另外在應用變化之後, 取消will-change的資源分配:

1
2
3
4
5
6
7
8
9
var el = document.getElementById('demo');

el.addEventListener('animationEnd', removeHint);

function removeHint() {

this.style.willChange = 'auto';

}

在使用will-change時, 還需注意:

  • 不要將will-change應用到太多元素上: 瀏覽器已經盡力嘗試去優化一切可以優化的東西了. 有一些更強力的優化, 如果與will-change結合在一起的話, 有可能會消耗很多機器資源, 如果過度使用的話, 可能導致頁面響應緩慢或者消耗非常多的資源.
  • 有節制地使用: 通常, 當元素恢復到初始狀態時, 瀏覽器會丟棄掉之前做的優化工作. 但是如果直接在樣式表中顯式聲明瞭will-change屬性, 則表示目標元素可能會經常變化, 瀏覽器會將優化工作保存得比之前更久. 所以最佳實踐是當元素變化之前和之後通過腳本來切換will-change的值.
  • 不要過早應用will-change優化: 如果你的頁面在性能方面沒什麼問題, 則不要添加will-change屬性來榨取一丁點的速度.will-change的設計初衷是作為最後的優化手段, 用來嘗試解決現有的性能問題. 它不應該被用來預防性能問題. 過度使用will-change會導致大量的內存佔用, 並會導致更復雜的渲染過程, 因為瀏覽器會試圖準備可能存在的變化過程. 這會導致更嚴重的性能問題.
  • 給它足夠的工作時間: 這個屬性是用來讓頁面開發者告知瀏覽器哪些屬性可能會變化的. 然後瀏覽器可以選擇在變化發生前提前去做一些優化工作. 所以給瀏覽器一點時間去真正做這些優化工作是非常重要的. 使用時需要嘗試去找到一些方法提前一定時間獲知元素可能發生的變化, 然後為它加上 will-change屬性.

參考資料