從手機滾動丟幀問題,學習瀏覽器合成與渲染層優化

一個 CSS 屬性引發的血案

Web 頁面性能是前端開發特別需要關注的重點,評判前端 Web 頁面性能的指標有很多,頁面的流暢度是其中的一種,如何讓頁面變得 “柔順絲滑”,要討論起來可就是個相當有料的話題了。之前開發移動端 H5 頁面的時候,就遇到過一個有趣的性能問題 —— 某個賣場頁面在 IOS 手機上出現了嚴重的卡頓,但在安卓機型下卻表現得十分流暢。歸納一下在 iPhoneX 上測試的具體表現:

  • 頁面加載時存在明顯的延遲,但通過代理抓到的網絡請求耗時並不比 Android 的高;

  • 頁面滾動時會出現短暫的局部白屏,即丟幀。

根據這些表徵情況不難推斷出,應該是有什麼東西在瘋狂佔用 CPU,卡住了渲染進程。

然而具體是什麼東西,要問我我也並不知道。對於這種沒法通過斷點定位到的問題,恐怕只有用上祖師爺親傳的 “代碼二分法” 才能制服得了了。一番艱苦排查之後,問題的根源終於聚焦到了下邊這行 CSS 代碼上:

  filter: blur(100px);

這行 CSS 代碼用於實現一個高斯模糊,來構造一個優惠券模塊的底部陰影。由於活動配置了多個優惠券,導致頁面裏存在多個設置了這個屬性的 div 元素,而 IOS 手機的瀏覽器似乎對這個屬性的渲染十分喫力(然而爲何喫力的原因不得而知),進而導致渲染進程的 CPU 佔用率過高,最終造成卡頓。

哦?CPU 忙不過來了?好辦嘛!我給優惠券模塊又加了這樣一行代碼,然後問題迎刃而解 ......

  will-change: transform;

你沒看錯,我也沒寫少,確實就是靠一行代碼解決的。

認識它的人可能已經看出來了,大致原理其實很簡單,這行代碼能夠開啓 GPU 加速頁面渲染,從而大大降低了 CPU 的負載壓力,達到優化頁面渲染性能的目的,不瞭解 CSS 硬件加速的可以看看這篇文章 Increase Your Site’s Performance with Hardware-Accelerated CSS[1]

問題解決了,但是真的就這麼完事了嗎?本着 “拔樹尋根” 的偉大原則,我把這個東西好好地研究了一番,才發現 GPU 加速其實沒那麼簡單。

瀏覽器渲染流程

在具體討論原理之前,我們需要了解一下瀏覽器渲染流程的一些基本概念。瀏覽器渲染流程是個老生常談的話題了,對於 “瀏覽器如何呈現一個頁面的內容” 的這類問題,不少人都可以講出一個相對完整的過程,從網絡請求到瀏覽器解析,可以具體到很多的細節。除去網絡資源獲取的步驟,我們理解的 Web 頁面的展示,一般可以分爲 構建 DOM 樹構建渲染樹佈局繪製渲染層合成 幾個步驟。

  • 構建 DOM 樹:瀏覽器將 HTML 解析成樹形結構的 DOM 樹,一般來說,這個過程發生在頁面初次加載,或頁面 JavaScript 修改了節點結構的時候。

  • 構建渲染樹:瀏覽器將 CSS 解析成樹形結構的 CSSOM 樹,再和 DOM 樹合併成渲染樹。

  • 佈局(Layout):瀏覽器根據渲染樹所體現的節點、各個節點的 CSS 定義以及它們的從屬關係,計算出每個節點在屏幕中的位置。Web 頁面中元素的佈局是相對的,在頁面元素位置、大小發生變化,往往會導致其他節點聯動,需要重新計算佈局,這時候的佈局過程一般被稱爲迴流(Reflow)。

  • 繪製(Paint):遍歷渲染樹,調用渲染器的 paint() 方法在屏幕上繪製出節點內容,本質上是一個像素填充的過程。這個過程也出現於迴流或一些不影響佈局的 CSS 修改引起的屏幕局部重畫,這時候它被稱爲重繪(Repaint)。實際上,繪製過程是在多個層上完成的,這些層我們稱爲渲染層(RenderLayer)。

  • 渲染層合成(Composite):多個繪製後的渲染層按照恰當的重疊順序進行合併,而後生成位圖,最終通過顯卡展示到屏幕上。

這是一個基本的瀏覽器從解析到繪製一個 Web 頁面的過程,跟上邊頁面卡頓問題的解決方法相關的,主要是最後一個環節 —— 渲染層合成。

渲染層合成

一、什麼是渲染層合成

在 DOM 樹中每個節點都會對應一個渲染對象(RenderObject),當它們的渲染對象處於相同的座標空間(z 軸空間)時,就會形成一個 RenderLayers,也就是渲染層。渲染層將保證頁面元素以正確的順序堆疊,這時候就會出現層合成(composite),從而正確處理透明元素和重疊元素的顯示。

這個模型類似於 Photoshop 的圖層模型,在 Photoshop 中,每個設計元素都是一個獨立的圖層,多個圖層以恰當的順序在 z 軸空間上疊加,最終構成一個完整的設計圖。

對於有位置重疊的元素的頁面,這個過程尤其重要,因爲一旦圖層的合併順序出錯,將會導致元素顯示異常。

二、瀏覽器的渲染原理

從瀏覽器的渲染過程中我們知道,頁面 HTML 會被解析成 DOM 樹,每個 HTML 元素對應了樹結構上的一個 node 節點。而從 DOM 樹轉化到一個個的渲染層,並最終執行合併、繪製的過程,中間其實還存在一些過渡的數據結構,它們記錄了 DOM 樹到屏幕圖形的轉化原理,其本質也就是樹結構到層結構的演化。

1、渲染對象(RenderObject)

一個 DOM 節點對應了一個渲染對象,渲染對象依然維持着 DOM 樹的樹形結構。一個渲染對象知道如何繪製一個 DOM 節點的內容,它通過向一個繪圖上下文(GraphicsContext)發出必要的繪製調用來繪製 DOM 節點。

2、渲染層(RenderLayer)

這是瀏覽器渲染期間構建的第一個層模型,處於相同座標空間(z 軸空間)的渲染對象,都將歸併到同一個渲染層中,因此根據層疊上下文,不同座標空間的的渲染對象將形成多個渲染層,以體現它們的層疊關係。所以,對於滿足形成層疊上下文條件的渲染對象,瀏覽器會自動爲其創建新的渲染層。能夠導致瀏覽器爲其創建新的渲染層的,包括以下幾類常見的情況:

  • 根元素 document

  • 有明確的定位屬性(relative、fixed、sticky、absolute)

  • opacity < 1

  • 有 CSS fliter 屬性

  • 有 CSS mask 屬性

  • 有 CSS mix-blend-mode 屬性且值不爲 normal

  • 有 CSS transform 屬性且值不爲 none

  • backface-visibility 屬性爲 hidden

  • 有 CSS reflection 屬性

  • 有 CSS column-count 屬性且值不爲 auto 或者有 CSS column-width 屬性且值不爲 auto

  • 當前有對於 opacity、transform、fliter、backdrop-filter 應用動畫

  • overflow 不爲 visible

DOM 節點和渲染對象是一一對應的,滿足以上條件的渲染對象就能擁有獨立的渲染層。當然這裏的獨立是不完全準確的,並不代表着它們完全獨享了渲染層,由於不滿足上述條件的渲染對象將會與其第一個擁有渲染層的父元素共用同一個渲染層,因此實際上,這些渲染對象會與它的部分子元素共用這個渲染層。

3、圖形層(GraphicsLayer)

GraphicsLayer 其實是一個負責生成最終準備呈現的內容圖形的層模型,它擁有一個圖形上下文(GraphicsContext),GraphicsContext 會負責輸出該層的位圖。存儲在共享內存中的位圖將作爲紋理上傳到 GPU,最後由 GPU 將多個位圖進行合成,然後繪製到屏幕上,此時,我們的頁面也就展現到了屏幕上。

所以 GraphicsLayer 是一個重要的渲染載體和工具,但它並不直接處理渲染層,而是處理合成層。

4、合成層(CompositingLayer)

滿足某些特殊條件的渲染層,會被瀏覽器自動提升爲合成層。合成層擁有單獨的 GraphicsLayer,而其他不是合成層的渲染層,則和其第一個擁有 GraphicsLayer 的父層共用一個。

那麼一個渲染層滿足哪些特殊條件時,才能被提升爲合成層呢?這裏列舉了一些常見的情況:

  • 3D transforms:translate3d、translateZ 等

  • video、canvas、iframe 等元素

  • 通過 Element.animate() 實現的 opacity 動畫轉換

  • 通過 СSS 動畫實現的 opacity 動畫轉換

  • position: fixed

  • 具有 will-change 屬性

  • 對 opacity、transform、fliter、backdropfilter 應用了 animation 或者 transition

因此,文首例子的解決方案,其實就是利用 will-change 屬性,將 CPU 消耗高的渲染元素提升爲一個新的合成層,才能開啓 GPU 加速的,因此你也可以使用 transform: translateZ(0) 來解決這個問題。

這裏值得注意的是,不少人會將這些合成層的條件和渲染層產生的條件混淆,這兩種條件發生在兩個不同的層處理環節,是完全不一樣的。

另外,有些文章會把 CSS Filter 也列爲影響 Composite 的因素之一,然而我驗證後發現並沒有效果。

三、隱式合成

上邊提到,滿足某些顯性的特殊條件時,渲染層會被瀏覽器提升爲合成層。除此之外,在瀏覽器的 Composite 階段,還存在一種隱式合成,部分渲染層在一些特定場景下,會被默認提升爲合成層。

對於隱式合成,CSS GPU Animation[2] 中是這麼描述的:

This is called implicit compositing: One or more non-composited elements that should appear above a composited one in the stacking order are promoted to composite layers. (一個或多個非合成元素應出現在堆疊順序上的合成元素之上,被提升到合成層。)

這句話可能不好理解,它其實是在描述一個交疊問題(overlap)。舉個例子說明一下:

  • 兩個 absolute 定位的 div 在屏幕上交疊了,根據 z-index 的關係,其中一個 div 就會”蓋在“了另外一個上邊。

  • 這個時候,如果處於下方的 div 被加上了 CSS 屬性:transform: translateZ(0),就會被瀏覽器提升爲合成層。提升後的合成層位於 Document 上方,假如沒有隱式合成,原本應該處於上方的 div 就依然還是跟 Document 共用一個 GraphicsLayer,層級反而降了,就出現了元素交疊關係錯亂的問題。

  • 所以爲了糾正錯誤的交疊順序,瀏覽器必須讓原本應該”蓋在“它上邊的渲染層也同時提升爲合成層。

四、層爆炸和層壓縮

1、層爆炸

從上邊的研究中我們可以發現,一些產生合成層的原因太過於隱蔽了,尤其是隱式合成。在平時的開發過程中,我們很少會去關注層合成的問題,很容易就產生一些不在預期範圍內的合成層,當這些不符合預期的合成層達到一定量級時,就會變成層爆炸。

層爆炸會佔用 GPU 和大量的內存資源,嚴重損耗頁面性能,因此盲目地使用 GPU 加速,結果有可能會是適得其反。CSS3 硬件加速也有坑[3] 這篇文章提供了一個很有趣的 DEMO[4],這個 DEMO 頁面中包含了一個 h1 標題,它對 transform 應用了 animation 動畫,進而導致被放到了合成層中渲染。由於 animation transform 的特殊性(動態交疊不確定),隱式合成在不需要交疊的情況下也能發生,就導致了頁面中所有 z-index 高於它的節點所對應的渲染層全部提升爲合成層,最終讓這個頁面整整產生了幾千個合成層。

消除隱式合成就是要消除元素交疊,拿這個 DEMO 來說,我們只需要給 h1 標題的 z-index 屬性設置一個較高的數值,就能讓它高於頁面中其他元素,自然也就沒有合成層提升的必要了。點擊 DEMO 中的複選按鈕就可以給 h1 標題加上一個較大的 z-index,前後效果對比十分明顯。

2、層壓縮

當然了,面對這種問題,瀏覽器也有相應的應對策略,如果多個渲染層同一個合成層重疊時,這些渲染層會被壓縮到一個 GraphicsLayer 中,以防止由於重疊原因導致可能出現的“層爆炸”。這句話不好理解,具體可以看看這個例子:

  • 還是之前的模型,只不過這次不同的是,有四個 absolute 定位的 div 在屏幕內發生了交疊。此時處於最下方的 div 在加上了 CSS 屬性 transform: translateZ(0) 後被瀏覽器提升爲合成層,如果按照隱式合成的原理,蓋在它上邊的 div 會提升爲一個新的合成層,第三個 div 又蓋在了第二個上,自然也會被提升爲合成層,第四個也同理。這樣一來,豈不是就會產生四個合成層了?

  • 然而事實並不是這樣的,瀏覽器的層壓縮機制,會將隱式合成的多個渲染層壓縮到同一個 GraphicsLayer 中進行渲染,也就是說,上方的三個 div 最終會處於同一個合成層中,這就是瀏覽器的層壓縮。

當然了,瀏覽器的自動層壓縮並不是萬能的,有很多特定情況下,瀏覽器是無法進行層壓縮的,無線性能優化:Composite[5] 這篇文章列舉了許多詳細的場景。

基於層合成的頁面渲染優化

一、層合成的得與失

層合成是一個相對複雜的瀏覽器特性,爲什麼我們需要關注這麼底層又難理解的東西呢?那是因爲渲染層提升爲合成層之後,會給我們帶來不少好處:

  • 合成層的位圖,會交由 GPU 合成,比 CPU 處理要快得多;

  • 當需要 repaint 時,只需要 repaint 本身,不會影響到其他的層;

  • 元素提升爲合成層後,transform 和 opacity 纔不會觸發 repaint,如果不是合成層,則其依然會觸發 repaint。

當然了,利弊是相對和共存的,層合成也存在一些缺點,這很多時候也成爲了我們網頁性能問題的根源所在:

  • 繪製的圖層必須傳輸到 GPU,這些層的數量和大小達到一定量級後,可能會導致傳輸非常慢,進而導致一些低端和中端設備上出現閃爍;

  • 隱式合成容易產生過量的合成層,每個合成層都佔用額外的內存,而內存是移動設備上的寶貴資源,過多使用內存可能會導致瀏覽器崩潰,讓性能優化適得其反。

二、Chrome Devtools 如何查看合成層

層合成的特性給我們提供了一個利用終端硬件能力來優化頁面性能的方式,對於一些重交互、重動畫的頁面,合理地利用層合成可以讓頁面的渲染效率得到極大提升,改善交互體驗。而我們需要關注的是如何規避層合成對頁面造成的負面影響,或者換個說法來講,更多時候是如何權衡利害,合理組織頁面的合成層,這就要求我們事先要對頁面的層合成情況有一個詳細的瞭解。Chrome Devtools 給我們提供了一些工具,可以方便的查看頁面的合成層情況。

首先是看看頁面的渲染情況,以一個欄目頁爲例,點擊 More tools -> Rendering,選擇 Layer borders,你就能看到頁面中的合成層都帶上了黃色邊框。

這還不夠,我們還需要更加詳盡的層合成情況,點擊 More tools -> Layers,你可以看到像這樣的一個視圖:

左側列出了所有提升爲獨立合成層的元素,右側則是一個整體合成層邊界視圖,以及選定合成層的詳細情況,包括以下幾個比較關鍵的信息:

  • Size:合成層的大小,其實也就是對應元素的尺寸;

  • Compositing Reasons:形成複合層原因,這是最關鍵的,也是我們分析問題的突破口,比如圖中的合成層產生的原因就是交疊問題;

  • Memory estimate:內存佔用估算;

  • Paint count:繪製次數;

  • Slow scroll regions:緩慢滾動區域。

可以看出我們在不經意間就已經制造出了很多意料之外的合成層,這些沒有實際意義的合成層都是可以被優化的。

三、一些優化建議

1、動畫使用 transform 實現

對於一些體驗要求較高的關鍵動畫,比如一些交互複雜的玩法頁面,存在持續變化位置的 animation 元素,我們最好是使用 transform 來實現而不是通過改變 left/top 的方式。這樣做的原因是,如果使用 left/top 來實現位置變化,animation 節點和 Document 將被放到了同一個 GraphicsLayer 中進行渲染,持續的動畫效果將導致整個 Document 不斷地執行重繪,而使用 transform 的話,能夠讓 animation 節點被放置到一個獨立合成層中進行渲染繪製,動畫發生時不會影響到其它層。並且另一方面,動畫會完全運行在 GPU 上,相比起 CPU 處理圖層後再發送給顯卡進行顯示繪製來說,這樣的動畫往往更加流暢。

2、減少隱式合成

雖然隱式合成從根本上來說是爲了保證正確的圖層重疊順序,但具體到實際開發中,隱式合成很容易就導致一些無意義的合成層生成,歸根結底其實就要求我們在開發時約束自己的佈局習慣,避免踩坑。

比如上邊提到的欄目頁面,就因爲平時開發的不注意造成頁面生成了過多的合成層,我在試圖查看頁面合成層情況的時候,在 PC 上已經能明顯感到卡頓了。利用 Chrome Devtools 分析之後不難發現,頁面裏邊存在的一個帶動畫 transform 的 button 按鈕,提升爲了合成層,動畫交疊的不確定性使得頁面內其他 z-index 大於它但其實並沒有交疊的節點也都全部提升爲了合成層(這個原因真的好坑)。

這個時候我們只需要把這個動畫節點的 z-index 屬性值設置得大一些,讓層疊順序高過於頁面其他無關節點就行。當然並不是盲目地設置 z-index 就能避免,有時候 z-index 也還是會導致隱式合成,這個時候可以試着調整一下文檔中節點的先後順序直接讓後邊的節點來覆蓋前邊的節點,而不用 z-index 來調整重疊關係。方法不是唯一的,具體方式還是得根據不同的頁面具體分析。

改善後的頁面效果如下,可以看到相比優化前,我們消除了很多無意義的合成層。

3、減小合成層的尺寸

舉個簡單的例子,分別畫兩個尺寸一樣的 div,但實現方式有點差別:一個直接設置尺寸 100x100,另一個設置尺寸 10x10,然後通過 scale 放大 10 倍,並且我們讓這兩個 div 都提升爲合成層:

<style>  .bottom, .top {    position: absolute;    will-change: transform;  }  .bottom {    width: 100px;    height: 100px;    top: 20px;    left: 20px;    z-index: 3;    background: rosybrown;  }  .top {    width: 10px;    height: 10px;    transform: scale(10);    top: 200px;    left: 200px;    z-index: 5;    background: indianred;  }</style><body>  <div class="bottom"></div>  <div class="top"></div></body>

利用 Chrome Devtools 查看這兩個合成層的內存佔用後發現,.bottom 內存佔用是 39.1 KB,而 .top 是 400 B,差距十分明顯。這是因爲 .top 是合成層,transform 位於的 Composite 階段,現在完全在 GPU 上執行。因此對於一些純色圖層來說,我們可以使用 width 和 height 屬性減小合成層的物理尺寸,然後再用 transform: scale(…) 放大,這樣一來可以極大地減少層合成帶來的內存消耗。


如果你覺得這篇內容對你有價值,請點贊,並關注我們的官網和我們的微信公衆號(WecTeam),每週都有優質文章推送:

參考資料

[1]

Increase Your Site’s Performance with Hardware-Accelerated CSS: https://blog.teamtreehouse.com/increase-your-sites-performance-with-hardware-accelerated-css

[2]

CSS GPU Animation: https://www.smashingmagazine.com/2016/12/gpu-animation-doing-it-right/

[3]

CSS3硬件加速也有坑: https://div.io/topic/1348

[4]

DEMO: http://fouber.github.io/test/layer/

[5]

無線性能優化:Composite: https://fed.taobao.org/blog/2016/04/26/performance-composite/

❤️ 看完三件事

如果你覺得這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)

  2. 關注我的博客 https://github.com/SHERlocked93/blog,讓我們成爲長期關係

  3. 關注公衆號「前端下午茶」,持續爲你推送精選好文,也可以加我爲好友,隨時聊騷。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章