實現達到 60FPS 的高效能互動動畫

發表於2017-10-31

譯者注:這篇大部分是老生常談,但也稍微有一些新東西呢,要看到最後哦 =)

高效能的 Web 互動動畫:如何達到 60FPS

每一個追求自然效果的產品都希望擁有一套順暢的互動流程。但開發者可能會忽略一些細節,導致出現效能糟糕的 Web 動畫,不僅會產生“頁面垃圾”(janky),最直接的體驗就是頁面卡頓。開發者往往會花大量精力在優化首屏載入,為了幾毫秒錙銖必較,但忽略了頁面互動動畫所帶來的效能問題。

Algolia 的每一位同事都很關注使用者體驗,「效能」一定是這個話題裡無法迴避的關鍵部分。動畫效能之於頁面的重要性,就像搜尋結果速度之於搜尋一樣。

成功的標準

動畫幀率可以作為衡量標準,一般來說畫面在 60fps 的幀率下效果比較好。換算一下就是,每一幀要在 16.7ms (16.7 = 60/1000) 內完成渲染。因此,我們的首要任務是減少不必要的效能消耗。 越多的幀需要渲染的,意味著有越多的任務需要瀏覽器處理,所以掉幀就出現了,這是達到 60fps 的一個絆腳石。如果所有動畫都無法在 16.7ms 渲染完畢,不如考慮用略低的 30fps 幀率來渲染。

瀏覽器 101:畫素是怎麼來的

在深入研究之前,我們要先搞清楚一個很重要的問題:瀏覽器是怎麼把程式碼轉化成為使用者可見的畫素點呢?

首次載入時,瀏覽器會下載並解析 HTML,將 HTML 元素轉變為一個 DOM 節點的「內容樹」(content tree)。除此之外,樣式同樣會被解析生成「渲染樹」 (render tree)。為了提升效能,渲染引擎會分開完成這些工作,甚至會出現渲染樹比 DOM 樹更快生成出來。

首次頁面載入時的 call tree

佈局

渲染樹生成後,瀏覽器會從頁面左上角開始迭代地計算出每個元素尺寸和位置,最終生成佈局。這個過程可能是一氣呵成的,但也可能由於元素的排列導致反覆地繪製。元素間的位置關係都緊密相關。為了優化必要的任務,瀏覽器會追蹤元素的變化情況,並將這些元素以及它們的子節點標記為 ‘dirty’(髒元素)。但是元素間耦合緊密,任何佈局上的改變代價都是重大的,應該儘量避免

繪製

生成佈局後,瀏覽器將頁面繪製到螢幕上。這個環節和「佈局」步驟類似,瀏覽器會追蹤髒元素,將它們合併到一個超大的矩形區域中。每一幀內只會發生一次重繪,用於繪製這個被汙染區域。重繪也會消耗大量效能,能免則免

複合

最後一步,將所有繪製好的元素進行復合。預設情況下,所有元素將會被繪製到同一個層中;如果將元素分開到不同的複合層中,更新元素對效能友好,不在同一層的元素不容易受到影響。CPU 繪製層,GPU 生成層。基礎繪圖操作在硬體加速合成中完成效率高。層的分離允許非破壞性的改變,正如你所猜測的,GPU 複合層上的改變代價最小效能消耗最少

激發創造力

一般情況下,更改複合層是相對消耗效能較少的一個操作,所以儘量通過改變 opacitytransform 的值觸發複合層繪製。看起來好像…我們能做出的效果會很有限,但真的是這樣嗎?要好好開發自己的創造力哦。

變換

「變換」為元素提供了無限的可能性:位置可以改變 (translateX, translateY, 或 translate3d)、大小也可以通過縮放 (scale) 改變、還能旋轉、斜切甚至 3D 變換。就是在某些場景下,開發者需要換一種思考方式,通過使用變換減少重排和重繪。 比如給一個元素新增 active 類名後它會向左移動 10px,可以通過改變 left 屬性:

也可以用能夠達到相同效果但效能更好的 translate

透明度

可以通過改變 opacity 的值,實現元素的顯示和隱藏(與改變 display 或者 visibility 的值達到類似的效果類似,但效能更好)。比如實現選單的切換效果:選單展開時,opacity 值為1;收起時,opacity 值變為 0。要注意的是 pointer-events 的值也要隨之改變,防止使用者操作到明明收起的選單。closed 類名會根據使用者點選 ‘open’ 時,closed 類名會被加上;點選 ‘close’ 按鈕時,closed 類名會被移除。對應的程式碼是這樣的:

另外,透明度可變意味著開發者可以控制元素的可見程度。多多思考應用透明度的場景 — 比如直接給元素的陰影 (box-shadow) 做動效很可能會造成嚴重的效能問題:

如果把陰影放到偽元素上,控制偽元素的透明度從而控制陰影,效果一樣但效能更好,程式碼如下:

手動優化

還有一個好訊息 — 開發者可以選擇想要控制的屬性,建立複合層,並將元素拖到該層。通過手動優化,確保元素總能被繪製好,這也是通知瀏覽器準備繪製該元素的最簡單方式。需要獨立層的場景包括:元素的狀態將發生一些變化(比如動畫)、改變了很消耗效能的樣式(比如 position:fixedoverflow:scroll)。可能你也見過了糟糕的效能導致了頁面閃爍、震動…或其他不如預期的效果,例如移動端常見的固定在視口頂部的頭部,會在頁面滾動的時候閃爍。將這樣的元素獨立到自己的複合層,就是常見的解決這類問題的方法。

hack 方法

從前,開發者通常是通過 backface-visibility:hidden 或者 trasform: translate3d(0,0,0) 觸發瀏覽器生成新的複合層,但這並不是標準的寫法,這兩種寫法也對元素的視覺效果不起作用。

新方法

現在有了will-change,它能夠顯式地通知瀏覽器對某一個元素的某個或某些元素做渲染優化。will-change 接收各種各樣的屬性值,比如一個或多個 CSS 屬性 (transform, opacity)、contents 或者 scroll-position。不過最常用值可能就是 auto,這個值表示的是瀏覽器將進行預設的優化:

優化有度,我們總能聽到關於「複合層過多反而阻礙渲染」的討論。因為瀏覽器已經為優化做了能做的一切, will-change 的效能優化方案本身對資源要求很高。如果瀏覽器持續在執行某個元素的 will-change,就意味著瀏覽器要持續對這個元素的進行優化,效能消耗造成頁面卡頓。過多的複合層降低頁面效能的現象在移動端很常見。

動畫方法

想要元素動起來可以用 CSS(宣告式),也可以使用 JavaScript(命令式),按需選擇。

宣告式動畫

CSS 動畫是宣告式的(告訴瀏覽器要做什麼),瀏覽器需要知道動畫的起始狀態和終止狀態,這樣它才知道如何優化。CSS 動畫不是在主執行緒中執行,不會妨礙主執行緒中的任務執行。總的來說,CSS 動畫對效能更友好。關鍵幀的動畫組合提供了相當豐富的視覺效果,比如下面是一個元素的無限旋轉動畫:

但 CSS 動畫缺乏 JS 的表達能力,將兩者結合起來效果更好:比如用 JS 監聽使用者輸入,根據動作切換類名。類名對應著不同的動畫效果。下面的程式碼實現的是當元素被點選時切換類名:

值得一提的是,如果你在操作「出血」(注:設計中在畫布四邊留出的一定區域稱為「出血」)時,新的 Web Animation API 會利用 CSS 的效能。通過這個 API,開發者能輕鬆地在效能友好的基礎上處理動畫的同步和時間問題。

命令式動畫

命令式動畫告訴瀏覽器如何去演繹動畫。CSS 動畫程式碼在某些場景下會變得很臃腫,或者需要更多的互動控制,此時 JS 就要介入了。注意!和 CSS 動畫不同,JS 動畫是在主執行緒中執行的(也就是說丟幀的可能性大於 CSS 動畫的),效能相對差一些。在使用 JS 動畫的場景中,考慮範圍中的效能之選比較少。

requestAnimationFrame

requestAnimationFrame 對效能友好,你可以將它視作 setTimeout 的進化版,不過這其實是一個動畫執行的 API。理論上呼叫了這個 API 就能保證 60fps 的幀率,但實踐證明這個函式是請求在下一次可用時繪製動畫,也就是並沒有固定的時間間隔。瀏覽器會把頁面上發生的變化組合接著一次繪製,而不會為每一次變化都進行繪製,通過這個方式提升 CPU 的使用率。 RAF 可以遞迴地使用:

另外,類似縮放視窗或頁面滾動這樣的場景,直接繫結事件是相對消耗效能的,開發者可以考慮在類似情況下用 RAF 提升效能。

滾動

實現效能良好的平滑滾動可是個挑戰。幸運的是,最近規範提供一些可配置選項。開發者不再需要通過禁止瀏覽器預設行為 (preventDefault),開啟 Passive event listeners 即可提升滾動效能(宣告之後,就不需要通過阻止元素的 touch 事件監聽和滑鼠滾輪事件監聽以優化滾動效能)。使用方法僅是在需要的監聽器中宣告 {passive: true}

從 Chrome 56 開始,這個選項將在 touchmovetouchstart 中預設開啟。

新出的 Intersection Observer API 能夠告訴開發者某個元素是不是在視口內,或者是不是和其他元素有互動。和通過事件處理這種會阻塞主執行緒的互動方式相比,Intersection Observer API 可以監聽元素,只有當元素交叉路徑的時候才會執行相應操作。這個 API 在無限滾動和懶載入的場景都可以使用。

先讀後寫

不斷地讀寫 DOM 會導致「強制同步佈局」(forced synchronous layouts),不過在技術發展過程中它演變成了更形象的詞 — 「佈局抖動」(layout thrashing)。前文也有提到,瀏覽器會追蹤「髒元素」,在合適的時候將變換過程儲存起來。在讀取了特定屬性以後,開發者可以強制瀏覽器提前計算。這樣反覆的讀寫會導致重排。幸運的是有一個簡單的解決方式:讀完再寫。

為了模擬上述效果,請看下面這個對讀寫有嚴苛要求的例子:

將「讀」放到 forEach 外面,而不是和「寫」一起在每個迭代裡都執行,就能提高效能:

優化的未來

瀏覽器在效能優化方面持續投入了越來越多的精力。通過新屬性 contain 可以宣告一個元素的子樹獨立於頁面的其他元素(目前只有 Chrome 和 Opera 支援該屬性)。這就等於告訴了瀏覽器「這個元素是安全的,它不會影響到其他元素」。contain 的屬性值根據變化的範圍確定,可以是 strictcontentsizelayoutstyle 或者 paint。這確保了子樹被更新的時候,不會造成父元素的重排。特別是在引入第三方控制元件的時候:

效能測試

知道了如何優化頁面效能後,還要做效能測試才行。依我之見,Chrome 開發者工具就是最棒的測試工具。在 ‘More Tools’ 中有一個 ‘Rendering’ 皮膚,其中包含了一些選項:比如追蹤「髒元素」、計算每秒的幀率、高亮每層的邊界還有監測滾動效能問題。

'Rendering' 皮膚中的可選項

‘Performance’ 皮膚中的 ‘Timeline’ 工具能記錄動畫過程,開發者可以直接定位到出問題的部分。很簡單,紅色表示有問題,綠色表示渲染正常。開發者可以直接點選紅色區域,看看是哪個函式造成了效能問題的函式。

另一個有趣的工具是在 ‘Caputrue Settings’ 中的 ‘CPU throtting’,開發者可以通過這個選項模擬頁面執行在一臺非常卡的裝置上。開發者在桌面瀏覽器上測試頁面的時候效果可能很好,那是因為 PC 或者 Mac 的本身效能就優於移動裝置。這個選項提供了很好的真機模擬。

一條合格的 'Timeline'

測試和迭代

動畫效能優化最簡單的方案就是減少每一幀的工作量。最有效緩解效能壓力的方法就是,儘量只更新在複合層中的元素,重新渲染複合層元素不容易影響到頁面上其他元素。效能優化往往意味著反覆地測試和驗證,以及跳出慣性思維找到奇技淫巧實現高效能動畫 — 無論怎麼樣,最終受益的會是使用者和開發者。

2
推薦閱讀

相關文章