引用
《瀏覽器工作原理與實踐》
本文主要講解渲染引擎的分層和合成機制,因為分層和合成機制代表了瀏覽器最為先進的合成技術,Chrome 團隊為了做到這一點,做了大量的優化工作。了解其工作原理,有助于拓寬你的視野,而且也有助于你更加深刻地理解 CSS 動畫和 JavaScript 底層工作機制。
一、顯示器是怎么顯示圖像的
每個顯示器都有固定的刷新頻率,通常是 60HZ,也就是每秒更新 60 張圖片,更新的圖片都來自于顯卡中一個叫前緩沖區的地方,顯示器所做的任務很簡單,就是每秒固定讀取 60 次前緩沖區中的圖像,并將讀取的圖像顯示到顯示器上。
那么這里顯卡做什么呢?
顯卡的職責就是合成新的圖像,并將圖像保存到后緩沖區中,一旦顯卡把合成的圖像寫到后緩沖區,系統就會讓后緩沖區和前緩沖區互換,這樣就能保證顯示器能讀取到最新顯卡合成的圖像。通常情況下,顯卡的更新頻率和顯示器的刷新頻率是一致的。但有時候,在一些復雜的場景中,顯卡處理一張圖片的速度會變慢,這樣就會造成視覺上的卡頓。
二、幀 VS 幀率
了解了顯示器是怎么顯示圖像的之后,下面再來明確下幀和幀率的概念,因為這是后續一切分析的基礎。
當你通過滾動條滾動頁面,或者通過手勢縮放頁面時,屏幕上就會產生動畫的效果。之所以你能感覺到有動畫的效果,是因為在滾動或者縮放操作時,渲染引擎會通過渲染流水線生成新的圖片,并發送到顯卡的后緩沖區。
大多數設備屏幕的更新頻率是 60 次 / 秒,這也就意味著正常情況下要實現流暢的動畫效果,渲染引擎需要每秒更新 60 張圖片到顯卡的后緩沖區。
把渲染流水線生成的每一副圖片稱為一幀
,把渲染流水線每秒更新了多少幀稱為幀率
,比如滾動過程中 1 秒更新了 60 幀,那么幀率就是 60Hz(或者 60FPS)。
由于用戶很容易觀察到那些丟失的幀,如果在一次動畫過程中,渲染引擎生成某些幀的時間過久,那么用戶就會感受到卡頓,這會給用戶造成非常不好的印象。
要解決卡頓問題,就要解決每幀生成時間過久的問題,為此 Chrome 對瀏覽器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分層和合成機制。分層和合成機制代表了當今最先進的渲染技術,所以接下來就來分析下什么是合成和渲染技術
三、如何生成一幀圖像
不過在開始之前,還需要聊一聊渲染引擎是如何生成一幀圖像的。這需要回顧下前面《06 | 渲染流程(下):HTML、CSS 和 JavaScript 文件,是如何變成頁面的?》介紹的渲染流水線。關于其中任意一幀的生成方式,有重排
、重繪
和合成
三種方式
這三種方式的渲染路徑是不同的,通常渲染路徑越長,生成圖像花費的時間就越多。比如重排,它需要重新根據 CSSOM 和 DOM 來計算布局樹,這樣生成一幅圖片時,會讓整個渲染流水線的每個階段都執行一遍,如果布局復雜的話,就很難保證渲染的效率了。而重繪因為沒有了重新布局的階段,操作效率稍微高點,但是依然需要重新計算繪制信息,并觸發繪制操作之后的一系列操作。
相較于重排和重繪,合成
操作的路徑就顯得非常短了,并不需要觸發布局和繪制兩個階段,如果采用了 GPU,那么合成的效率會非常高。
所以,關于渲染引擎生成一幀圖像的幾種方式,按照效率推薦合成方式優先,若實在不能滿足需求,那么就再退后一步使用重繪或者重排的方式。
本文的焦點在合成上,所以接下來就來深入分析下 Chrome 瀏覽器是怎么實現合成操作的。Chrome 中的合成技術,可以用三個詞來概括總結:分層
、分塊
和合成
四、分層和合成
通常頁面的組成是非常復雜的,有的頁面里要實現一些復雜的動畫效果,比如點擊菜單時彈出菜單的動畫特效,滾動鼠標滾輪時頁面滾動的動畫效果,當然還有一些炫酷的 3D 動畫特效。如果沒有采用分層機制,從布局樹直接生成目標圖片的話,那么每次頁面有很小的變化時,都會觸發重排或者重繪機制,這種“牽一發而動全身”的繪制策略會嚴重影響頁面的渲染效率。
為了提升每幀的渲染效率,Chrome 引入了分層和合成的機制。那該怎么來理解分層和合成機制呢?
你可以把一張網頁想象成是由很多個圖片疊加在一起的,每個圖片就對應一個圖層,Chrome 合成器最終將這些圖層合成了用于顯示頁面的圖片。如果你熟悉 PhotoShop 的話,就能很好地理解這個過程了,PhotoShop 中一個項目是由很多圖層構成的,每個圖層都可以是一張單獨圖片,可以設置透明度、邊框陰影,可以旋轉或者設置圖層的上下位置,將這些圖層疊加在一起后,就能呈現出最終的圖片了。
在這個過程中,將素材分解為多個圖層的操作就稱為分層,最后將這些圖層合并到一起的操作就稱為合成。所以,分層和合成通常是一起使用的。
考慮到一個頁面被劃分為兩個層,當進行到下一幀的渲染時,上面的一幀可能需要實現某些變換,如平移、旋轉、縮放、陰影或者 Alpha 漸變,這時候合成器只需要將兩個層進行相應的變化操作就可以了,顯卡處理這些操作駕輕就熟,所以這個合成過程時間非常短。
理解了為什么要引入合成和分層機制,下面再來看看 Chrome 是怎么實現分層和合成機制的。
在 Chrome 的渲染流水線中,分層體現在生成布局樹之后,渲染引擎會根據布局樹的特點將其轉換為層樹(Layer Tree),層樹是渲染流水線后續流程的基礎結構。
層樹中的每個節點都對應著一個圖層,下一步的繪制階段就依賴于層樹中的節點。在《06 | 渲染流程(下):HTML、CSS 和 JavaScript 文件,是如何變成頁面的?》中介紹過,繪制階段其實并不是真正地繪出圖片,而是將繪制指令組合成一個列表,比如一個圖層要設置的背景為黑色,并且還要在中間畫一個圓形,那么繪制過程會生成|Paint BackGroundColor:Black | Paint Circle|這樣的繪制指令列表,繪制過程就完成了。
有了繪制列表之后,就需要進入光柵化階段了,光柵化就是按照繪制列表中的指令生成圖片。每一個圖層都對應一張圖片,合成線程有了這些圖片之后,會將這些圖片合成為“一張”圖片,并最終將生成的圖片發送到后緩沖區。這就是一個大致的分層、合成流程。
需要重點關注的是,合成操作是在合成線程上完成的,這也就意味著在執行合成操作時,是不會影響到主線程執行的。這就是為什么經常主線程卡住了,但是 CSS 動畫依然能執行的原因
。
五、分塊
如果說分層是從宏觀上提升了渲染效率,那么分塊則是從微觀層面提升了渲染效率
。
通常情況下,頁面的內容都要比屏幕大得多,顯示一個頁面時,如果等待所有的圖層都生成完畢,再進行合成的話,會產生一些不必要的開銷,也會讓合成圖片的時間變得更久。
因此,合成線程會將每個圖層分割為大小固定的圖塊,然后優先繪制靠近視口的圖塊,這樣就可以大大加速頁面的顯示速度
。不過有時候, 即使只繪制那些優先級最高的圖塊,也要耗費不少的時間,因為涉及到一個很關鍵的因素——紋理上傳,這是因為從計算機內存上傳到 GPU 內存的操作會比較慢。
為了解決這個問題,Chrome 又采取了一個策略:在首次合成圖塊的時候使用一個低分辨率的圖片。比如可以是正常分辨率的一半,分辨率減少一半,紋理就減少了四分之三。在首次顯示頁面內容的時候,將這個低分辨率的圖片顯示出來,然后合成器繼續繪制正常比例的網頁內容,當正常比例的網頁內容繪制完成后,再替換掉當前顯示的低分辨率內容。這種方式盡管會讓用戶在開始時看到的是低分辨率的內容,但是也比用戶在開始時什么都看不到要好
六、如何利用分層技術優化代碼
通過上面的介紹,相信你已經理解了渲染引擎是怎么將布局樹轉換為漂亮圖片的,理解其中原理之后,你就可以利用分層和合成技術來優化代碼了。
在寫 Web 應用的時候,你可能經常需要對某個元素做幾何形狀變換、透明度變換或者一些縮放操作,如果使用 JavaScript 來寫這些效果,會牽涉到整個渲染流水線,所以 JavaScript 的繪制效率會非常低下。
這時你可以使用 will-change 來告訴渲染引擎你會對該元素做一些特效變換,CSS 代碼如下:
.box {
will-change: transform, opacity;
}
這段代碼就是提前告訴渲染引擎 box 元素將要做幾何變換和透明度變換操作,這時候渲染引擎會將該元素單獨實現一層,等這些變換發生時,渲染引擎會通過合成線程直接去處理變換,這些變換并沒有涉及到主線程,這樣就大大提升了渲染的效率。這也是 CSS 動畫比 JavaScript 動畫高效的原因。
所以,如果涉及到一些可以使用合成線程來處理 CSS 特效或者動畫的情況,就盡量使用 will-change 來提前告訴渲染引擎,讓它為該元素準備獨立的層。但是凡事都有兩面性,每當渲染引擎為一個元素準備一個獨立層的時候,它占用的內存也會大大增加,因為從層樹開始,后續每個階段都會多一個層結構,這些都需要額外的內存,所以你需要恰當地使用 will-change。
七、總結
- 首先介紹了顯示器顯示圖像的原理,以及幀和幀率的概念,然后基于幀和幀率又介紹渲染引擎是如何實現一幀圖像的。通常渲染引擎生成一幀圖像有三種方式:重排、重繪和合成。其中重排和重繪操作都是在渲染進程的主線程上執行的,比較耗時;而合成操作是在渲染進程的合成線程上執行的,執行速度快,且不占用主線程。
- 然后重點介紹了瀏覽器是怎么實現合成的,其技術細節主要可以使用三個詞來概括:分層、分塊和合成。
- 最后還講解了 CSS 動畫比 JavaScript 動畫高效的原因,以及怎么使用 will-change 來優化動畫或特效。
思考時間
觀察下面代碼,結合 Performance 面板、內存面板和分層面板,全面比較在 box 中使用 will-change 和不使用 will-change 的效率、性能和內存占用等情況。
<html>
<head><title> 觀察 will-change</title><style>.box {will-change: transform, opacity;display: block;float: left;width: 40px;height: 40px;margin: 15px;padding: 10px;border: 1px solid rgb(136, 136, 136);background: rgb(187, 177, 37);border-radius: 30px;transition: border-radius 1s ease-out;}body {font-family: Arial;}</style>
</head><body><div id="controls"><button id="start">start</button><button id="stop">stop</button></div><div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div><div class="box"> 旋轉盒子 </div></div><script>let boxes = document.querySelectorAll('.box'); let boxes1 = document.querySelectorAll('.box1'); let start = document.getElementById('start');let stop = document.getElementById('stop');let stop_flag = falsestart.addEventListener('click', function () {stop_flag = falserequestAnimationFrame(render);})stop.addEventListener('click', function () {stop_flag = true})let rotate_ = 0let opacity_ = 0function render() { if(stop_flag)return 0 rotate_ = rotate_ + 6if( opacity_ > 1)opacity_ = 0opacity_ = opacity_ + 0.01let command = 'rotate('+rotate_ + 'deg)';for (let index = 0; index < boxes.length; index++) {boxes[index].style.transform = commandboxes[index].style.opacity = opacity_}requestAnimationFrame(render);}</script>
</body></html>