無線性能優化:Composite

一個 Web 頁面的展示,簡單來說可以認為經歷了以下下幾個步驟。

- JavaScript:一般來說,我們會使用 JavaScript 來實現一些視覺變化的效果。比如做一個動畫或者往頁面里添加一些 DOM 元素等。
- Style:計算樣式,這個過程是根據 CSS 選擇器,對每個 DOM 元素匹配對應的 CSS 樣式。這一步結束之后,就確定了每個 DOM 元素上該應用什么 CSS 樣式規則。
- Layout:布局,上一步確定了每個 DOM 元素的樣式規則,這一步就是具體計算每個 DOM 元素最終在屏幕上顯示的大小和位置。web 頁面中元素的布局是相對的,因此一個元素的布局發生變化,會聯動地引發其他元素的布局發生變化。比如, <body> 元素的寬度的變化會影響其子元素的寬度,其子元素寬度的變化也會繼續對其孫子元素產生影響。因此對于瀏覽器來說,布局過程是經常發生的。
- Paint:繪制,本質上就是填充像素的過程。包括繪制文字、顏色、圖像、邊框和陰影等,也就是一個 DOM 元素所有的可視效果。一般來說,這個繪制過程是在多個層上完成的。
- Composite:渲染層合并,由上一步可知,對頁面中 DOM 元素的繪制是在多個層上進行的。在每個層上完成繪制過程之后,瀏覽器會將所有層按照合理的順序合并成一個圖層,然后顯示在屏幕上。對于有位置重疊的元素的頁面,這個過程尤其重要,因為一旦圖層的合并順序出錯,將會導致元素顯示異常。
當然,本文我們只來關注 Composite 部分。
瀏覽器渲染原理
在討論 Composite 之前,有必要先簡單了解下一些瀏覽器(本文只是針對 Chrome 來說)的渲染原理,方便對之后一些概念的理解。更多詳細的內容可以參閱 GPU Accelerated Compositing in Chrome
注:由于 Chrome 對 Blank 引擎某些實現的修改,某些我們之前熟知的類名有了變化,比如 RenderObject 變成了 LayoutObject,RenderLayer 變成了 PaintLayer。感興趣的看以參閱 Slimming Paint 。
在瀏覽器中,頁面內容是存儲為由 Node 對象組成的樹狀結構,也就是 DOM 樹。每一個 HTML element 元素都有一個 Node 對象與之對應,DOM 樹的根節點永遠都是 Document Node。這一點相信大家都很熟悉了,但其實,從 DOM 樹到最后的渲染,需要進行一些轉換映射。

從 Nodes 到 LayoutObjects
DOM 樹中得每個 Node 節點都有一個對應的 LayoutObject 。LayoutObject 知道如何在屏幕上 paint Node 的內容。
從 LayoutObjects 到 PaintLayers
一般來說,擁有相同的坐標空間的 LayoutObjects,屬于同一個渲染層(PaintLayer)。PaintLayer 最初是用來實現 stacking contest(層疊上下文) ,以此來保證頁面元素以正確的順序合成(composite),這樣才能正確的展示元素的重疊以及半透明元素等等。因此滿足形成層疊上下文條件的 LayoutObject 一定會為其創建新的渲染層,當然還有其他的一些特殊情況,為一些特殊的 LayoutObjects 創建一個新的渲染層,比如 overflow != visible 的元素。根據創建 PaintLayer 的原因不同,可以將其分為常見的 3 類:
-
NormalPaintLayer
- 根元素(HTML)
- 有明確的定位屬性(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 應用動畫
-
OverflowClipPaintLayer
- overflow 不為 visible
-
NoPaintLayer
- 不需要 paint 的 PaintLayer,比如一個沒有視覺屬性(背景、顏色、陰影等)的空 div。
滿足以上條件的 LayoutObject 會擁有獨立的渲染層,而其他的 LayoutObject 則和其第一個擁有渲染層的父元素共用一個。
從 PaintLayers 到 GraphicsLayers
某些特殊的渲染層會被認為是合成層(Compositing Layers),合成層擁有單獨的 GraphicsLayer,而其他不是合成層的渲染層,則和其第一個擁有 GraphicsLayer 父層公用一個。
每個 GraphicsLayer 都有一個 GraphicsContext,GraphicsContext 會負責輸出該層的位圖,位圖是存儲在共享內存中,作為紋理上傳到 GPU 中,最后由 GPU 將多個位圖進行合成,然后 draw 到屏幕上,此時,我們的頁面也就展現到了屏幕上。
渲染層提升為合成層的原因有一下幾種:
注:渲染層提升為合成層有一個先決條件,該渲染層必須是 SelfPaintingLayer(基本可認為是上文介紹的 NormalPaintLayer)。以下所討論的渲染層提升為合成層的情況都是在該渲染層為 SelfPaintingLayer 前提下的。
-
直接原因(direct reason)
- 硬件加速的 iframe 元素(比如 iframe 嵌入的頁面中有合成層) demo
- video 元素
- 覆蓋在 video 元素上的視頻控制欄
-
3D 或者 硬件加速的 2D Canvas 元素
-
硬件加速的插件,比如 flash 等等
- 在 DPI 較高的屏幕上,fix 定位的元素會自動地被提升到合成層中。但在 DPI 較低的設備上卻并非如此,因為這個渲染層的提升會使得字體渲染方式由子像素變為灰階(詳細內容請參考: Text Rendering )
- 有 3D transform
- backface-visibility 為 hidden
-
對 opacity、transform、fliter、backdropfilter 應用了 animation 或者 transition(需要是 active 的 animation 或者 transition,當 animation 或者 transition 效果未開始或結束后,提升合成層也會失效)
-
will-change 設置為 opacity、transform、top、left、bottom、right(其中 top、left 等需要設置明確的定位屬性,如 relative 等) demo
-
后代元素原因
-
overlap 重疊原因
為什么會因為重疊原因而產生合成層呢?舉個簡單的栗子。

藍色的矩形重疊在綠色矩形之上,同時它們的父元素是一個 GraphicsLayer。此時假設綠色矩形為一個 GraphicsLayer,如果 overlap 無法提升合成層的話,那么藍色矩形不會提升為合成層,也就會和父元素公用一個 GraphicsLayer。

此時,渲染順序就會發生錯誤,因此為保證渲染順序,overlap 也成為了合成層產生的原因,也就是如下的正常情形。

當然 overlap 的原因也會細分為幾類,接下來我們會詳細看下。
-
重疊或者說部分重疊在一個合成層之上。
那如何算是重疊呢,最常見和容易理解的就是元素的 border box(content + padding + border) 和合成層的有重疊,比如: demo ,當然 margin area 的重疊是無效的( demo )。其他的還有一些不常見的情況,也算是同合成層重疊的條件,如下:
-
假設重疊在一個合成層之上(assumedOverlap)。
這個原因聽上去有點虛,什么叫假設重疊?其實也比較好理解,比如一個元素的 CSS 動畫效果,動畫運行期間,元素是有可能和其他元素有重疊的。針對于這種情況,于是就有了 assumedOverlap 的合成層產生原因,示例可見: demo 。在本 demo 中,動畫元素視覺上并沒有和其兄弟元素重疊,但因為 assumedOverlap 的原因,其兄弟元素依然提升為了合成層。
需要注意的是該原因下,有一個很特殊的情況:
如果合成層有內聯的 transform 屬性,會導致其兄弟渲染層 assume overlap,從而提升為合成層。比如: demo 。
-
層壓縮
基本上常見的一些合成層的提升原因如上所說,你會發現,由于重疊的原因,可能隨隨便便就會產生出大量合成層來,而每個合成層都要消耗 CPU 和內存資源,豈不是嚴重影響頁面性能。這一點瀏覽器也考慮到了,因此就有了層壓縮(Layer Squashing)的處理。如果多個渲染層同一個合成層重疊時,這些渲染層會被壓縮到一個 GraphicsLayer 中,以防止由于重疊原因導致可能出現的“層爆炸”。具體可以看如下 demo 。一開始,藍色方塊由于
translateZ 提升為了合成層,其他的方塊元素因為重疊的原因,被壓縮了一起,大小就是包含這 3 個方塊的矩形大小。

當我們 hover 綠色方塊時,會給其設置 translateZ 屬性,導致綠色方塊也被提升為合成層,則剩下的兩個被壓縮到了一起,大小就縮小為包含這 2 個方塊的矩形大小。

當然,瀏覽器的自動的層壓縮也不是萬能的,有很多特定情況下,瀏覽器是無法進行層壓縮的,如下所示,而這些情況也是我們應該盡量避免的。(注:以下情況都是基于重疊原因而言)
-
無法進行會打破渲染順序的壓縮(squashingWouldBreakPaintOrder)
示例如下: demo
<style> #ancestor { -webkit-mask-image: -webkit-linear-gradient(rgba(0,0,0,1), rgba(0,0,0,0)); } #composited { width: 100%; height: 100%; transform: translateZ(0); } #container { position: relative; width: 400px; height: 60px; border: 1px solid black; } #overlap-child { position: absolute; left: 0; top: 0 ; bottom: 0px; width: 100%; height: 60px; background-color: orange; } </style>
<div id="container">
<div id="composited">Text behind the orange box.</div>
<div id="ancestor">
<div id="overlap-child"></div>
</div>
</div>
在本例中,`#overlap-child` 同合成層重疊,如果進行壓縮,會導致渲染順序的改變,其父元素 `#ancestor` 的 mask 屬性將失效,因此類似這種情況下,是無法進行層壓縮的。目前常見的產生這種原因的情況有兩種,一種是上述的祖先元素使用 mask 屬性的情況,另一種是祖先元素使用 filter 屬性的情況([demo](http://taobaofed.github.io/demo/performance-composite-demo/squash/squashingWouldBreakPaintOrder-filter.html))。
-
video 元素的渲染層無法被壓縮同時也無法將別的渲染層壓縮到 video 所在的合成層上(squashingVideoIsDisallowed) demo
-
iframe、plugin 的渲染層無法被壓縮同時也無法將別的渲染層壓縮到其所在的合成層上(squashingLayoutPartIsDisallowed) demo
-
無法壓縮有 reflection 屬性的渲染層(squashingReflectionDisallowed) demo
-
無法壓縮有 blend mode 屬性的渲染層(squashingBlendingDisallowed) demo
-
當渲染層同合成層有不同的裁剪容器(clipping container)時,該渲染層無法壓縮(squashingClippingContainerMismatch)。
示例如下: demo
<style> .clipping-container { overflow: hidden; height: 10px; background-color: blue; } .composited { transform: translateZ(0); height: 10px; background-color: red; } .target { position:absolute; top: 0px; height:100px; width:100px; background-color: green; color: #fff; } </style>
<div class="clipping-container">
<div class="composited"></div>
</div>
<div class="target">不會被壓縮到 composited div 上</div>
本例中 `.target` 同 合成層 `.composited` 重疊,但是由于 `.composited` 在一個 `overflow: hidden` 的容器中,導致 `.target` 和合成層有不同的裁剪容器,從而 `.target` 無法被壓縮。
-
相對于合成層滾動的渲染層無法被壓縮(scrollsWithRespectToSquashingLayer)
示例如下: demo
<style> body { height: 1500px; overflow-x: hidden; } .composited { width: 50px; height: 50px; background-color: red; position: absolute; left: 50px; top: 400px; transform: translateZ(0); } .overlap { width: 200px; height: 200px; background-color: green; position: fixed; left: 0px; top: 0px; } </style>
<div class="composited"></div>
<div class="overlap"></div>
本例中,紅色的 `.composited` 提升為了合成層,綠色的 `.overlap` fix 在頁面頂部,一開始只有 `.composited` 合成層。  當滑動頁面,`.overlap` 重疊到 `.composited` 上時,`.overlap` 會因重疊原因提升為合成層,同時,因為相對于合成層滾動,因此無法被壓縮。 
-
當渲染層同合成層有不同的具有 opacity 的祖先層(一個設置了 opacity 且小于 1,一個沒有設置 opacity,也算是不同)時,該渲染層無法壓縮(squashingOpacityAncestorMismatch,同 squashingClippingContainerMismatch) demo
-
當渲染層同合成層有不同的具有 transform 的祖先層時,該渲染層無法壓縮(squashingTransformAncestorMismatch,同上) demo
-
當渲染層同合成層有不同的具有 filter 的祖先層時,該渲染層無法壓縮(squashingFilterAncestorMismatch,同上) demo
-
當覆蓋的合成層正在運行動畫時,該渲染層無法壓縮(squashingLayerIsAnimating),當動畫未開始或者運行完畢以后,該渲染層才可以被壓縮 demo

如何查看合成層
使用 Chrome DevTools 工具來查看頁面中合成層的情況。
比較簡單的方法是打開 DevTools,勾選上 Show layer borders

其中,頁面上的合成層會用黃色邊框框出來。

當然,更加詳細的信息可以通過 Timeline 來查看。
每一個單獨的幀,看到每個幀的渲染細節:

點擊之后,你就會在視圖中看到一個新的選項卡:Layers。

點擊這個 Layers 選項卡,你會看到一個新的視圖。在這個視圖中,你可以對這一幀中的所有合成層進行掃描、縮放等操作,同時還能看到每個渲染層被創建的原因。

有了這個視圖,你就能知道頁面中到底有多少個合成層。如果你在對頁面滾動或漸變效果的性能分析中發現 Composite 過程耗費了太多時間,那么你可以從這個視圖里看到頁面中有多少個渲染層,它們為何被創建,從而對合成層的數量進行優化。
性能優化
提升為合成層簡單說來有以下幾點好處:
- 合成層的位圖,會交由 GPU 合成,比 CPU 處理要快
- 當需要 repaint 時,只需要 repaint 本身,不會影響到其他的層
- 對于 transform 和 opacity 效果,不會觸發 layout 和 paint
利用合成層對于提升頁面性能方面有很大的作用,因此我們也總結了一下幾點優化建議。
提升動畫效果的元素
合成層的好處是不會影響到其他元素的繪制,因此,為了減少動畫元素對其他元素的影響,從而減少 paint,我們需要把動畫效果中的元素提升為合成層。
提升合成層的最好方式是使用 CSS 的 will-change 屬性。從上一節合成層產生原因中,可以知道 will-change 設置為 opacity、transform、top、left、bottom、right 可以將元素提升為合成層。
#target {
will-change: transform;
}
其兼容如下所示:

對于那些目前還不支持 will-change 屬性的瀏覽器,目前常用的是使用一個 3D transform 屬性來強制提升為合成層:
#target {
transform: translateZ(0);
}
但需要注意的是,不要創建太多的渲染層。因為每創建一個新的渲染層,就意味著新的內存分配和更復雜的層的管理。之后我們會詳細討論。
如果你已經把一個元素放到一個新的合成層里,那么可以使用 Timeline 來確認這么做是否真的改進了渲染性能。別盲目提升合成層,一定要分析其實際性能表現。
使用 transform 或者 opacity 來實現動畫效果
文章最開始,我們講到了頁面呈現出來所經歷的渲染流水線,其實從性能方面考慮,最理想的渲染流水線是沒有布局和繪制環節的,只需要做合成層的合并即可:

為了實現上述效果,就需要只使用那些僅觸發 Composite 的屬性。目前,只有兩個屬性是滿足這個條件的:transforms 和 opacity。更詳細的信息可以查看 CSS Triggers 。
注意:元素提升為合成層后,transform 和 opacity 才不會觸發 paint,如果不是合成層,則其依然會觸發 paint。具體見如下兩個 demo。
可以看到未提升 target element 為合成層,transform 和 opacity 依然會觸發 paint。
減少繪制區域
對于不需要重新繪制的區域應盡量避免繪制,以減少繪制區域,比如一個 fix 在頁面頂部的固定不變的導航 header,在頁面內容某個區域 repaint 時,整個屏幕包括 fix 的 header 也會被重繪,見 demo ,結果如下:

而對于固定不變的區域,我們期望其并不會被重繪,因此可以通過之前的方法,將其提升為獨立的合成層。
減少繪制區域,需要仔細分析頁面,區分繪制區域,減少重繪區域甚至避免重繪。
合理管理合成層
看完上面的文章,你會發現提升合成層會達到更好的性能。這看上去非常誘人,但是問題是,創建一個新的合成層并不是免費的,它得消耗額外的內存和管理資源。實際上,在內存資源有限的設備上,合成層帶來的性能改善,可能遠遠趕不上過多合成層開銷給頁面性能帶來的負面影響。同時,由于每個渲染層的紋理都需要上傳到 GPU 處理,因此我們還需要考慮 CPU 和 GPU 之間的帶寬問題、以及有多大內存供 GPU 處理這些紋理的問題。
對于合成層占用內存的問題,我們簡單做了幾個 demo 進行了驗證。
demo 1 和 demo 2 中,會創建 2000 個同樣的 div 元素,不同的是 demo 2 中的元素通過 will-change 都提升為了合成層,而兩個 demo 頁面的內存消耗卻有很明顯的差別。

防止層爆炸
通過之前的介紹,我們知道同合成層重疊也會使元素提升為合成層,雖然有瀏覽器的層壓縮機制,但是也有很多無法進行壓縮的情況。也就是說除了我們顯式的聲明的合成層,還可能由于重疊原因不經意間產生一些不在預期的合成層,極端一點可能會產生大量的額外合成層,出現層爆炸的現象。我們簡單寫了一個極端點但其實在我們的頁面中比較常見的 demo 。
<style>
@-webkit-keyframes slide {
from { transform: none; }
to { transform: translateX(100px); }
}
.animating {
width: 300px;
height: 30px;
background-color: orange;
color: #fff;
-webkit-animation: slide 5s alternate linear infinite;
}
ul {
padding: 5px;
border: 1px solid #000;
}
.box {
width: 600px;
height: 30px;
margin-bottom: 5px;
background-color: blue;
color: #fff;
position: relative;
/* 會導致無法壓縮:squashingClippingContainerMismatch */
overflow: hidden;
}
.inner {
position: absolute;
top: 2px;
left: 2px;
font-size: 16px;
line-height: 16px;
padding: 2px;
margin: 0;
background-color: green;
}
</style>
<!-- 動畫合成層 -->
<div class="animating">composited animating</div>
<ul>
<!-- assume overlap -->
<li class="box">
<!-- assume overlap -->
<p class="inner">asume overlap, 因為 squashingClippingContainerMismatch 無法壓縮</p>
</li>
...
</ul>
demo 中, .animating 的合成層在運行動畫,會導致 .inner 元素因為上文介紹過的 assumedOverlap 的原因,而被提升為合成層,同時, .inner 的父元素 .box 設置了 overflow: hidden ,導致 .inner 的合成層因為 squashingClippingContainerMismatch 的原因,無法壓縮,就出現了層爆炸的問題。

這種情況平時在我們的業務中還是很常見的,比如 slider + list 的結構,一旦滿足了無法進行層壓縮的情況,就很容易出現層爆炸的問題。
解決層爆炸的問題,最佳方案是打破 overlap 的條件,也就是說讓其他元素不要和合成層元素重疊。對于上述的示例,我們可以將 .animation 的 z-index 提高。修改后 demo
.animating {
...
/* 讓其他元素不和合成層重疊 */
position: relative;
z-index: 1;
}
此時,就只有 .animating 提升為合成層,如下:

同時,內存占用比起之前也降低了很多。

如果受限于視覺需要等因素,其他元素必須要覆蓋在合成層之上,那應該盡量避免無法層壓縮情況的出現。針對上述示例中,無法層壓縮的情況(squashingClippingContainerMismatch),我們可以將 .box 的 overflow: hidden 去掉,這樣就可以利用瀏覽器的層壓縮了。修改后 demo
此時,由于第一個 .box 因為 squashingLayerIsAnimating 的原因無法壓縮,其他的都被壓縮到了一起。

同時,內存占用比起之前也降低了很多。

最后
之前無線開發時,大多數人都很喜歡使用 translateZ(0) 來進行所謂的硬件加速,以提升性能,但是性能優化并沒有所謂的“銀彈”, translateZ(0) 不是,本文列出的優化建議也不是。拋開了對頁面的具體分析,任何的性能優化都是站不住腳的,盲目的使用一些優化措施,結果可能會適得其反。因此切實的去分析頁面的實際性能表現,不斷的改進測試,才是正確的優化途徑。
參考
- PaintLayer.h
- PaintLayer.cpp
- CompositingReasons.cpp
- CompositingReasons.h
- CompositingRequirementsUpdater.cpp
- chrome layout test
- Slimming Paint
- The stacking contest
- Blink Compositing Update: Recap and Squashing
- GPU Accelerated Compositing in Chrome
- CSS Triggers
- google render performance
來自: http://taobaofed.org/blog/2016/04/25/performance-composite/


