示例代碼托管在:http://www.github.com/dashnowords/blogs
部落格園位址:《大史住在大前端》原創博文目錄
華為雲社群位址:【你要的前端打怪更新指南】
目錄
- 一. 高性能動畫
- 二. 像素渲染管線
- 基本渲染流程
- 回流和重繪
- 三. 舊軟體渲染
- 渲染對象(RenderObject)
- 渲染層(RenderLayer)
- 四. 從canvas體會分層優勢
- 不分層的情況
- 分層繪制
- 層的合并
- 五.小結
動畫的流暢程度通常是以
FPS
(Frame Per Second,每秒幀率)作為衡量的。在錄影機錄制視訊時每一幀實際上包含了一段時間内的畫面記錄(長曝光攝影的道理相同的),如果畫面裡的事物在運動,那麼暫停播放時看到的畫面通常都是模糊的,這樣的畫面也被稱為“模糊幀”,加上雙眼“視覺暫留”效果的影響,影視作品一般隻要達到
24FPS
就可以展示出看起來連續運動的畫面;而在頁面的渲染中,每一幀都是由計算機計算渲染出來的精确畫面,幀和幀之間并不存在模糊過渡,是以通常認為需要達到
50FPS~60FPS
的幀率,才能夠得到較好的觀看體驗。
為了達到盡可能接近
60FPS
以上的幀率,浏覽器每一幀的計算和繪制所花費的時間就需要控制在1000/60≈16.6ms以内,根據Google開發者社群提供的資料,開發者最好能夠将所有的工作控制在
10ms
左右,以便給浏覽器一些處理内部工作的時間,否則就無法在限定的時間内完成畫面更新,動态的内容就會表現出卡頓,對使用者體驗造成負面影響。下一節就來看一下,在這
16ms
的時間裡,浏覽器都需要完成哪些任務。
談起浏覽器的工作流程,你可能會在大多數文章中見過下面這張圖:
它直覺地描述了浏覽器如何将
HTML
檔案和
CSS
樣式檔案通過逐漸處理最終合成渲染樹并展示在頁面上的過程,當然其中每一步都是非常複雜的,如果你對此還不熟悉,可以通過【浏覽器的工作原理:新式網絡浏覽器幕後揭秘】這篇文章進行了解(極力推薦這篇文章!)。但實際上上面的流程裡并沒有覆寫網站的整個生命周期,它隻是描述了從使用者擷取到網站首頁和資源檔案後到完成首屏渲染這段時間内所做的工作,盡管工作流程幾乎是一緻的,但諸如響應使用者的互動動作,在頁面上實作動畫等等内容,隻通過上面的宏觀原理圖了解起來還是很困難的。當開發者談及浏覽器渲染性能的話題時,我們通常會聽到“重排”、“重繪”等術語,實際上它們就是對這後半部分工作的描述,它被稱為“浏覽器像素渲染管線”,此時就需要祭出Google開發者社群提供的基本原理圖:
編寫在
JavaScript
代碼中的那些事件監聽器、定時任務等等異步觸發的代碼就會在橙色的部分執行,這部分代碼運作在主線程中,如果有問題的代碼或是執行時間較長的代碼在其中造成了阻塞,後續的幾個步驟就隻能等着,這會直接延緩頁面的渲染甚至導緻頁面直接崩潰,當
JavaScript
執行完一個宏任務并清空了目前的微任務隊列後,就會開始UI渲染流程,進入下一個環節。
在
Style
階段需要找出發生變更的樣式并重新計算相關的尺寸,當然在首屏渲染之前第一次處理
CSS
樣式時,浏覽器肯定已經對計算結果進行了緩存,以便在這像素渲染管線處理時節省時間。
計算完樣式本身後,就需要進入
Layout
階段,重新來計算發生樣式變動的元素應該以怎樣的盒模型尺寸繪制在畫面上的哪個位置,網頁中的基本排版遵循正常文檔流的規則,是以一個元素尺寸變化後,就有可能需要重新計算其父子元素或臨近元素的位置,不難想象這是一個極容易引發蝴蝶效應的環節。完成了
Layout
布局後,可以看到圖中使用的顔色也發生了變化,因為相對而言它們的開銷就比較輕量了。
Paint
階段就是生成像素資料的過程,它會将元素的背景、邊框、陰影等等可見的部分繪制出來,它們可能會被繪制在多個層上。
Composite
階段,由于繪制階段生成的畫面可能分布于多個層,那麼最終渲染的結果就需要将它們按照一定的順序完成畫面的重疊,這就是浏覽在合成階段主要的工作,當然這個過程并不一定是由
CPU
獨自完成的,後面還會講到。當動畫執行時,浏覽器會不斷建立幀,上面的過程就會反複發生,進而實作幀畫面的不斷變動:
不同的
CSS
樣式的性能開銷和造成的影響是不同的,是以上面的像素渲染管路的各個階段并不一定都有工作要做,如果發生變更的元素樣式不會造成布局變化,那麼
layout
階段就不需要做什麼工作,如果發生變更的
CSS
屬性也可以不用重新計算各部分的像素顔色,那麼
paint
階段也就沒有什麼工作要做,這樣渲染管路就被簡化成為:
這是我們最期望得到的理想狀态。如果發生變化的
CSS
屬性導緻
Layout
階段任務量的增加,這類情況就被稱為“回流”或“重排”,如果發生變化的
CSS
屬性導緻了
Paint
階段任務量的增加,這類情況就被稱為“重繪”,它的開銷相比
Layout
而言更小,從管線的特征不難明白,“回流”必然會導緻“重繪”,但反之則不一定成立。
隻通過
Composite
階段的工作就可以處理的
CSS
屬性就是
opacity
(透明度)和
transform
(變形),它們是各類場景中優先推薦使用的性能最高的特性,
transform
可以很友善地模拟出位置變化,在可以忽略畫面精度的情況下(例如純色的背景)也可以使用
scale
來模拟尺寸變化。
是以在滿足需求的前提下,我們當然希望選擇改變性能開銷更小的屬性,以便可以在
16ms
的時間内完成整個渲染管線的任務,這裡所說的性能,通常是指持續修改樣式時的性能開銷,暫不讨論低頻的頁面狀态變動。關于
CSS
屬相詳細的性能開銷,可以在【CSS Triggers】檢視詳情,每個浏覽器的實作上有細微的差别。
opacity
和
transform
的動畫性能開銷最小,并不是因為處理它們造成的影響時工作量減小了,而是因為這兩個屬性造成的影響可以在圖層合成時可以委托給強大的
GPU
來執行。
GPU
的基本架構和
CPU
不同,它擁有更多算術邏輯單元(也就是
ALU
),這使得它非常适合以并行計算的形式執行計算密集型任務,例如圖形的矩陣變換、人工神經網絡的訓練等等。
而
opacity
transform
造成的影響,都可以通過改變圖層合成時的參數來進行處理,換句話說就是它可以直接使用之前生成的位圖像素資料的緩存,而不需要再重新計算,也不用更新像素資料緩存,配合上
GPU
強大的算力,性能自然很能打。
現代浏覽器多采用軟硬體混合渲染的方式來處理,軟體渲染的方式通常也被成為“舊軟體渲染”(與之相對應的是硬體加速渲染),“舊”隻是出現時間比較早,并不表示它已經被硬體渲染所取代。最初的網頁并不是作為完整的應用存在的,而隻是用來做一些資訊展示,二維渲染的場景居多(因為頁面上大多都是基于“盒模型”的矩形區域和文字包圍盒的計算和繪制),這時使用
CPU
渲染的性能并不低,“舊軟體渲染”通常使用底層的二維圖形繪制庫,你可以借助
HTML Canvas 2D API
來類比了解,在
canvas
畫闆上實作的二維動畫,即使在逐幀動畫中進行覆寫式的全畫布重繪,也能夠保持較高的幀率;對
3D圖形學
有一定了解的小夥伴都知道,
3D
渲染引擎隻支援點、線和三角形的繪制,是以一個矩形就至少需要2個三角形來表示(當然也可是多個),直覺感覺上就是一種“殺雞用牛刀”的體驗,
GPU
的算力雖然很牛逼,但通常記憶體空間非常有限,是以最好隻在必要時有節制地使用
GPU
。
本節我們先忘掉
GPU
的加速能力,來看看軟體中需要如何處理頁面渲染。下面以
WebKit
核心為例來說明一下渲染的基本處理過程以及建立合成層的條件。想要進一步了解的小夥伴可以嘗試閱讀朱永勝的《WebKit技術内幕》一書(不要輕易嘗試,很容易覺得自己不适合搞前端,甚至懷疑人生)。
DOM
樹解析時,浏覽器會為可見元素建立一個
RenderObject
類的執行個體,用于記錄繪制這個節點需要的一些資訊和方法,
RenderObject
會依據HTML中的DOM結構生成一棵
RenderObjectTree
,但浏覽器并沒有直接使用它來生成一張位圖畫面,因為如果這樣做的話,頁面上發生任何變化時,都需要重新計算變更的區域并更新緩存,它的确很節省空間,畢竟隻需要緩存一張靜态圖檔中各個像素點的顔色資料就可以了,但節省空間的代價就是無法節省時間,這樣的政策會加重重複運算的負擔。
為了友善處理,
WebKit
會根據
RenderObjectTree
來對
RenderObject
進行按層分類,并最終建立一棵包含多個渲染圖層資訊的
RenderLayerTree
(渲染層樹),兩棵樹中的節點并不是一一對應的,當周遊
RenderObjectTree
時,隻有符合一定條件的節點(比如擷取了上下文的canvas節點、video節點、具有透明樣式的節點等等,詳細的規則會根據平台實作不同可能會有變化)會建立出新的
RenderLayer
節點,而其他的節點隻需要添加到祖先節點上已經存在的
RenderLayer
節點上就可以了。規則如下:
除了根節點以外,一個節點的父親,就是它對應的
RenderLayer
節點的祖先鍊中最近的祖先,且兩者所在的
RenderObject
不是同一個。
RenderLayer
根據《Webkit技術内幕》一書中的介紹,在軟體渲染中,每一個
RenderLayer
對象都會有一個後端類,用來存儲該層繪制的結果(但是在硬體渲染中由于合成層的存在,是以并不會為每一個
RenderLayer
生成後端類),你可以把後端類簡單地了解為結果緩存,
CPU
會将各個
RenderLayer
的結果最終渲染為到一張位圖裡,然後交給
GPU
展示,合成的過程也可以在
GPU
中進行,也就是硬體加速渲染,這裡不再展開,但是僅考慮軟體渲染環節的話,RenderLayer樹就已經可以實作目的了。用過
photoshop
的使用者可能會對分層這種處理形式比較熟悉,它的關鍵點就是在處理有重疊的區域時必須考慮先後順序。
直接看概念可能比較繞,做個簡單的比喻,比如碼農小強的爺爺有自己的房子,然後生了幾個孩子,這些孩子裡有的發展的比較好就自己買房單獨住處去了,發展的不太好的隻能住在爺爺家裡,接着每個孩子又生了一堆孩子,也就是小強這一輩,當然也是發展的有好有差,以碼農小強為例,發展的好的就可以自己買房子住,發展的不好的就得拼爹了,如果他爹有房子,就可以住在爹家,如果很悲劇他爹也沒房子,那他就得和他爹一起住到他爹的爹家裡去(說住到墳墓裡的你放學别走),
RenderObject
到
RenderLayer
的生成過程也是類似的。
Webkit
底層的
2D
渲染使用
Skia
庫,它是類似于
Canvas API
的二維圖形繪制庫,為了友善了解軟體渲染的優勢,下面通過
Canvas API
來看看分層到底帶來了哪些變化,本例中我們先不考慮重新計算布局的情況,僅考慮重繪的工作。以下圖為例(如果不了解
canvas
動畫繪制,可以參考筆者曾經寫的一篇相關博文【響應式程式設計的思維藝術 (2)響應式Vs面向對象】):
假設在下面的分析中,
地面
、
天空
山
雲
人
是分别繪制上去的,人物和雲是可以水準運動的,人比山距離觀察者更近。
canvas
中,使用
context.getImageData(x, y, width, height)
方法取得畫布上對應矩形區域的像素資料,在不分層的情況下,假設第一次渲染後,使用這個方法将畫布中的像素資料取出來存儲在
backUp
變量上(像素資料是一個很長的一維數組,按順序逐行存儲着畫面中每個像素點的
rgba
4個值),也就是隻為最終結果建立了一份緩存,此時實際上已經丢失了一部分資訊了,例如雲和天空、人和天空都有重疊的部分,而重疊部分的像素隻保留了最上面一層的值。
當需要繪制逐幀動畫時,問題就來了。人物是運動的,那麼程式自然知道下一幀應該将人物繪制在什麼地方,但是如果直接繪制,原來的人物仍然會留在圖中,這樣逐幀畫下去,畫面上就會留下一排人物運動的分解畫面,這顯然是不行的;如果把人物先擦掉呢?也是不行的,這樣雖然可以保持畫面上隻有一個跑動的人物,但是因為畫面被緩存時,像素已經被覆寫掉了,如果把人物擦掉,隻從緩存的資料中,是無法知道被擦掉的這部分像素點應該被修複成什麼樣子的,例如下圖中,緩存中是上一幀的資料複原後的圖,但是如果下一幀人物離開了原位置,原來的畫面就無法利用緩存直接恢複了,例如上圖中紅框中的部分就留下了人物的殘影。
假設在上面的畫面中,人物的大小是
100*100
,緩存的像素中,其位置是
(200,400)
,假設一幀中它平移了
10
個像素,那麼就可以粗略地認為需要更新的區域是左上角為
(200,400)
,寬
110
,高
100
的矩形區域。盡管這個
110*100
的矩形區域可能隻占了整個緩存區域的
10%
,也就是大部分緩存的像素點還是有效的,但為了修複這部分畫面,程式将不得不重新計算每個對象的繪制結果,然後将這個區域的畫面按照層次重新繪制上去,在上面的示例中,變更區擦除後從下到上依次要繪制天空、山和人物,人物是繪制在最上層的以便可以完整顯示,人物離開後的空白像素也在重繪中被修複。
單幅位圖像素緩存的劣勢其實已經很明顯了,下面再來看看分層的情況,假如上述畫面中的對象分别繪制在不同的
canvas
畫布上,那麼一共就需要5個
canvas
元素,由于畫布是透明底色的,是以最終顯示結果是疊加而成的。接着為每個
canvas
層都生成像素資料的緩存,那麼在面對同樣的更新場景時,天空、地面、山和雲都可以不用操作,而隻需要更新人物所在的
canvas
層,先将受影響的區域擦除,接着重新計算人物的繪制結果并更新單層的緩存,最後将新的結果繪制到目标位置上,相比之下,分層緩存的方案使用了更多的存儲空間來緩存繪制的像素資料,但減少了更新時的計算量,是典型的空間換時間的做法。
顯示器上最終呈現的是一幅位圖畫面,是以即使在上面的示例中使用了5個分布在不同層次的
canvas
标簽,實際上計算機在處理時仍然會對各層的像素資料按層進行合并計算。上面的示例中存在一個很容易發現的優化點,就是無論怎麼重繪,實際上
山
地面
的繪制結果都會擋住對應區域的
天空
的繪制結果,而且它們都是靜态的,是以
天空
的緩存資料中,與
山
地面
重疊的部分實際上沒什麼用,如果更新的區域發生在重疊區,那麼更新畫面的時候,
天空
層總是要先繪制一次然後再被更高層的
山
或者
地面
覆寫掉,這時候就可以利用層合并的思想進行優化,也就是直接将天空,山和地面繪制在同個
canvas
上,它們整體的繪制結果緩存時隻需要占用原來1/3的空間(3張位圖變1張了),但對于後續的重繪卻不會造成影響,這樣就可以省掉很大一部分确定沒有用的緩存。當然上面的示例隻是比較簡單的情況,在DOM節點渲染結果的處理時有更加複雜的層劃分和層合并的規則,但是優化的思想基本是一樣的。
從直接繪制到分層繪制再到層的合并的過程,實際上就是從DOM節點到
RenderObject樹
再到
RenderLayer樹
的變換過程,利用
canvas
的執行個體就比較容易了解軟體渲染過程中的一些政策了,很多東西你覺得不了解,并不一定是因為它本身有多複雜,隻是因為你無法知道它是為了解決什麼問題而存在的,實際上當你面對同樣的問題時,可能也會采取類似甚至更好的處理政策,但當我們隻看别人描述解決方案時,通常都會感覺到一個東西“特别複雜”或者“特别高大上”,是以請永遠保持謙遜,但也别丢了你的自信。最後分享一個最近很喜歡的冷段子,下一期再見。
問:"從前有一隻菜鳥,他特别菜,但是他仍然在飛,請問為什麼?"
答:“因為他有一顆勇敢的心!”