本文系翻譯整理的 BlinkOn9 會議演講內容前端
在 BlinkOn9
會議中,Google Blink 團隊開發者 Philip Rogers 與 Stefan Zager 進行了《Blink Rendering - Rebuilding the Engine Mid-Flight》分享,旨在介紹 Blink 渲染的基本原理與開發團隊近期對滾動性能、繪製合成與排版的改進。瀏覽器
簡單來講,渲染是瀏覽器的某種基礎功能,它將你的 HTML 和 CSS 解析成 DOM 樹,並將其轉換成屏幕上的像素點。緩存
圖中顯示了 document
生命週期的主要階段,中間四個黑色框是渲染流水線(render pipeline
)。安全
我一直認爲研究 Chrome 的追蹤器有助於理解 document 生命週期。所以,下圖是一個渲染進程的 Chrome 追蹤器面板,圖中的高亮區域是渲染主線程,底部的一小部分屬於合成器線程(compositor thread
)。在渲染的開始,咱們可能會處理資源加載,運行 JavaScript,修改 DOM 樹等等,其間會有一段空閒階段,用於處理通常任務。性能優化
接下來,就會發生 VSync
(垂直同期,Vertical Synchronization)。vsync 是瀏覽器剛剛將一個滿滿的像素窗口推到顯示器上,而且開始生成下一個像素窗口了。所以對於渲染進程來講,這意味着全員都已作好準備生成新的像素點。前端工程師
vsync 觸發了 BeginMainFrame
,這是一個重要方法,它驅動了渲染流水線。BeginMainFrame
首先會處理輸入事件,如滾動、觸屏、手勢、鼠標等,而後會運行 requestAnimationFrame
回調。架構
接下來即是開始執行渲染流水線了,以下圖,共有四個步驟:ide
style: 將 DOM 樹轉化爲 layout 樹,遍歷 layout 樹爲每個節點標註其樣式信息,而後將帶有樣式信息的 layout 樹傳遞到下一階段函數
layout: 咱們將再次遍歷 layout 樹,爲節點標註其尺寸、位置信息,至此咱們已兩次對 layout 樹進行標註,而後將它傳遞給合成階段
composition setup: 在合成設置階段咱們會肯定須要繪製多少個合成層(compositing layers
),以及它們的尺寸、位置、層疊順序等
paint: 繪製階段會獲取 layout 樹的標註以及在合成設置階段所記錄信息,而後建立一個由原始繪圖命令組成的「顯示列表」,它會指示合成器如何進行像素繪製。
在繪製階段的結尾,會由主線程切換到合成線程(即下圖追蹤器中的綠色區域),將光柵化工做切分紅幾個「瓦片」,分配給幾個工做線程來進行。待光柵化完成,咱們將進入 Chrome 合成器。這一過程會循環往復地執行下去。
以上即是關於渲染的簡單介紹,值得注意的一點是,主線程很是繁忙,全部動做都發生在主線程,腳本在主線程運行,還負責了渲染和許多其它功能,所以主線程是很是擁擠的。通過多年的優化工做,咱們發現一個很是有效的優化方式,就是把主線程的工做切分,交給其它線程處理。
對於 Web 平臺來講,渲染是很是重要的。
一是由於,動態網頁的本質是接受用戶或腳本生成的輸入,並將其轉化爲視覺結果。渲染是這個過程的核心,所以不管你的頁面作的有多麼酷炫,若是渲染出了問題,用戶就不會有任何好的體驗。
其二,渲染是網頁性能的主要決定因素(感知的和實際的),渲染是沒法中斷的,若是 JavaScript 運行過久頁面就會變得笨重,這固然會引發用戶注意。
其三,現代網頁是動態的——會不斷地修改內容,加載內容,進行動畫。爲了跟上步伐,保證交互流暢,渲染代碼必須是一等公民。
下面開始介紹咱們在渲染代碼中遇到的挑戰,以及爲了解決這些問題咱們正在着手進行的改進。
正如前文所說,渲染是網頁性能的主要決定因素,而滾動體驗則是其重中之重。用戶對於滾動體驗是很是敏感的,滾動的體驗決定了其對頁面總體性能的感知,若是滾動體驗很糟糕,頁面再酷炫也拯救不了。Blink 中涉及到滾動的代碼巧妙地隱藏在各處,跨越了渲染器中的主線程與合成線程,甚至包括瀏覽器進程。
回首歷史,在 1998 年 KHTML
的原始版本中首次賦予了 document
滾動能力。其後,2003 年 WebKit
中 div 也能夠進行滾動了,然而這兩種滾動都須要從新觸發渲染流水線來進行。起初,這兩種滾動的代碼是分開編寫的,這也沒什麼大不了的。
然而幾年以後,隨着對滾動添加了不少功能,作了不少優化,這些關於滾動的代碼直接變成了 Blink 中最複雜也最難懂的部分。咱們依然維護着這兩套滾動代碼,全部的功能都要寫兩遍。不只如此,因爲滾動屬於核心代碼,實現其它功能也不免要去修改它,複雜度直線上升,愈來愈難以維護了。
因爲目前滾動代碼的現狀,以及任何功能改動都要寫兩遍,咱們全部開發者的工做都變得很困難,所以,在 2014 年 Steve Kobus 與 Elliott 想到了一個絕妙的主意:經過根層滾動(Root Layer Scrolling
)來解決這個問題。
他們決定取消 document
文檔級滾動,只使用 overflow
實現全部的滾動功能,這一決定主要是爲了下降代碼的複雜度,改善代碼質量。除此以外還有別的好處,好比,因爲兩套代碼已經分別維護了很長時間,他們的行爲表現也並不一致。實際上,文檔級滾動行爲有明顯差別,這是由於文檔級滾動與 div 滾動會有一些徹底不相關的 Bug,一種滾動有 Bug,另外一張滾動可能沒有,真是一團糟。
實現根層滾動也是一個漫長艱辛的過程,歷經 4 年,終於完成,在 M66 版本交付。
想要大規模改動修改渲染代碼的佈局部分,第一件事是要經過大約四萬五千個佈局測試,上圖中測試失敗次數是由 1500 開始的,事實上,咱們剛開始進行修改時,大約有 6000 個測試都失敗了。這些測試都須要分門別類,挨個解決,所以在這個過程當中咱們又順便解決了不少歷史遺留 Bug。
在咱們的性能基準測試圖中能夠發現,在咱們剛開展工做時,性能有了一次明顯退化,大概退化了 40% 到 50%,隨着深刻研究這些性能 Bug,咱們發現這些是深遞歸到 CPU 路徑的代碼,所以咱們必須作 CPU 相關優化與 Chrome chromium 部分的代碼修改。這是一個很是艱難的過程,要各類不一樣的代碼修復才能讓咱們真正回到基線性能。
因此我也不得不重申,這塊代碼真的很難處理,若是咱們犯了任何錯誤,用戶都會當即發現,這些錯誤也會影響全部頁面。
接下來咱們來了解一下關於繪製與合成咱們所作的改進。
同滾動代碼同樣,繪製與合成部分的代碼也至關古老,大概已經有 16 年了,在當前的代碼架構中開發新功能實屬不易。如今有機會對這一部分代碼進行性能優化,下降內存佔用,使得代碼易於擴展,便於開發新功能。所以咱們開展了一個綜合工程項目:繪製代碼瘦身。
有必要先從技術方面概述繪製是什麼,爲何它如此酷炫,以及咱們在總體項目中所處的位置。所以,咱們先從前文所提到的滾動是如何工做的開始吧。
在過去,若是咱們想進行 div 滾動,咱們須要重繪出每一幀。這意味着若是用戶一直拖動滾輪,咱們就須要生成全部的像素點,用戶須要等待咱們運行整個渲染流水線後才能夠繼續移動。
這裏有一個驚人的創新叫作合成線程滾動(composited threaded scrolling
),其中有兩個部分,一個是合成,這很像從電子遊戲中得到的靈感,其思想是將整個可滾動區域繪製到一個圖像圖形緩衝區中,而後並非每一幀重繪移動區域,而是將一個子紋理複製到不一樣的紋理中。第二個創新是將滾動操做脫離出主線程,還記得前文提到過的吧,主線程的資源是多麼寶貴,此處的基本思想是咱們能夠在 JavaScript 運行的同時進行滾動。這兩件事結合在一塊兒,是一項很是驚人的創新,這種合成線程渲染的思想能夠推廣到任何須要對紋理進行修改的地方。
好比說,transform,opacity,filter,clip 等等這些均可以經過合成線程思想來實現。當你在軟件上運行,用 CPU 繪製像素時,速度很快,可是若是在 GPU 上運行,它的速度更會快成一道閃電。
可是這裏有一個叫「老巢爆炸(lair explosion
)」的問題。以下圖,若是咱們將綠盒子使用合成線程進行旋轉,它會貫穿藍盒子。問題是咱們須要確認藍盒子會被繪製在綠盒子之上,所以藍盒子也會被合成。這種狀況會佔用至關多的內存。你做爲一名前端工程師,在頁面上設置了透明度,有可能你就忽然發現內存爆炸了,由於頁面上其它部分也都被合成了。
下面來介紹一下當下合成器架構體系來闡述合成器是如何工做的,繪製代碼瘦身又有什麼樣的成效。
咱們有一個簡單的 DOM 樹結構,有 emoji 笑臉表情的 div 是能夠滾動的。它的生命週期與前文所述的並沒有二致,所以在排版環節咱們將標註 layout 樹的尺寸與位置信息,而後即是合成設置環節了,咱們重點講一下。
a、b、d 都不可滾動,因此它們仨能夠一塊兒繪製到同一個圖形緩衝區中(graphics buffer
)。而 emoji 笑臉表情是能夠滾動的,咱們不想爲它的滾動重繪每一幀,所以把它單獨放到一個圖形緩衝區中。如今咱們有了兩個圖形緩衝區,是時候進行繪製了。
在繪製過程當中,咱們其實是遍歷 layout 樹,記錄繪圖命令。而後是進行光柵化。
此時咱們將執行繪製步驟中所記錄的繪圖命令,生成真正的像素點。
最終咱們將在頁面上它們安放到一塊兒,上下滾動 emoji 表情時也不會觸發重繪步驟了。
在目前的架構體系下,有兩個問題,一是合成僅限於特定子樹。layout 樹有一個屬性,決定咱們可否進行合成。並不是全部子樹都有這個屬性,所以咱們不能隨意將頁面上的 div 轉換成圖形緩衝區,這致使了一個基本性合成 Bug,在 2014 年首次發現。
當時咱們試圖讓 iframe 在任意地方合成,以提升滾動性能,結果發現頁面上的內容瞬間都消失了,緣由是若是製做了一個合成的 iframe,你還須要確保任何繪製在它上方的內容也是合成的。這是一個在 2014 年發現的毀滅性錯誤,由於你已經創建了這些特殊的邏輯來不建立過多的圖形緩衝區處理諸如此類的事情,結果在遊戲的後期發現了一種基本的缺陷,這種缺陷束縛了你的手,這並非是把你的手綁在一個邊緣案例中,這一個可能遇到的狀況(Gmail 在進行滾動優化時就遇到了這個問題,優化沒法生效),這阻止了咱們繼續在當前架構中構建。
咱們當前合成體繫結構的第二個問題是合成設置是在繪製以前完成的。咱們在系統早期就建立了圖像緩衝區,你須要在繪製步驟中從新計算,因此咱們有重複的邏輯,很難描述這個邏輯有多複雜,可是我能夠說大約一半的繪製代碼是用於這種大小和效果,好比 clip。
除了在繪製以前進行這種合成設置以外,還有一個問題,由於它在主線程上,這意味着任何可能改變繪製對象大小的效果都須要回到主線程。例如,若是你有兩個能夠合成的盒子,其中一個是能夠滾動的,那麼在不少狀況下你必須假設最壞的狀況。你必須假設合成器能夠在頁面上的任何地方進行,因此你必須爲頁面上的許多東西建立圖像緩衝區,這是咱們以前討論過的老巢爆炸問題,致使了真正的性能問題。
繪製代碼瘦身項目改變了咱們整個架構中的這兩個問題。它改變了咱們如何選擇合成事物的粒度,這樣你就能夠合成,將任何效果轉換成圖像緩衝區,第二是咱們將合成設置移動到繪製後。這不只能夠解決基礎性合成 Bug,也避免了邏輯重複。
所以,新的合成架構能夠在任何邊界進行合成,咱們已經移動了合成設置應用程序,以釋放主線程的壓力。這使咱們可以對重疊的事物作出精確的合成決定,能夠作一些改變主線程外繪製對象大小的事情。
在這個項目的里程碑中,咱們已經完成了關於繪製緩存的功能,目前處於 M67,剛剛發佈了繪製代碼瘦身的 V1.75 版本。在今年(2018)年末,咱們將發佈 V2 版本,將合成設置移動到繪製後進行。
佈局有兩個主要問題,第一個是 web 平臺問題,咱們稱之爲組合問題(The Combinatorial Problem
)。咱們有大量的 web 標準,而且還在不斷添加更多新的標準,同時舊的標準也依然存在,每次咱們定義新的 CSS 標準時,它都會建立一組帶有與全部現有 CSS 標準的新交互。它們結合的方式有一點奇怪,隨之而來有不少的邊界 case,讓咱們以 flexbox
爲例看一看:
很簡單的三個 flex item 盒子,咱們添加幾個屬性看看佈局會發生什麼變化。
設置 direction: rtl
會使得佈局方向變爲從右往左。
在此基礎上,添加一個 flex-direction: row-reverse
,佈局方向又恢復爲從左往右了。
把 direction
屬性去掉,從右往左排布。
flex-direction
設置爲 columb-reverse
,佈局改成按列排布。
設置 writing-mode
同時 flex-direction
改成行排布,使得文字方向也發生了改變。
flex-direction
改成反向,依然複合預期。
flex-direction
改成列,也是同樣。舉例到這裏就足夠了,以上之因此表現複合預期,是由於我花了三週的時間解決各類 Bug。
在其它內核的瀏覽器中可就不必定了,如上圖,第一個圖是以上 flexbox 示例在 chromium 中的表現,第一排第二個瀏覽器表現也幾乎相同,然而第三個第四個可就相去甚遠。
我無心 diss 其它瀏覽器,換個功能示例,可能 chromium 就是表現最差的那一個。我是想強調這個兼容性問題確實存在,複雜的 CSS 特性也在持續堆積。
第二個問題是 Blink 中佈局相關的代碼是很是遠古的,裏面充斥着無封裝,不可重入,非線程安全的麪條式巨石代碼。
先解釋一下巨石代碼,這裏有一個 layout 樹,節點是 layout 對象,假設咱們在樹下面的一個元素上改變 CSS。元素如今變髒了,須要轉發出去。接下來咱們要作的是標記整個祖先鏈,當咱們想執行 layout 階段時,咱們老是從樹頂開始,一直往下走,如今咱們進行了一系列優化,可是優化後的也沒有跳過不少步驟。
咱們仍然要進行完整的樹遍歷,這也是耗費資源的,每次咱們執行 layout 都會進行遍歷。底部節點可能位於一個尺寸固定的盒子裏,它甚至可使用 CSS containment
,這是一個新特性,有點相似於瀏覽器的契約,意味着這個子樹不會影響它自身之外的任何東西,子樹之外的任何東西也不會影響它。
若是佈局這棵子樹時咱們已經有了全部咱們所須要的信息,無需在這個子樹以外尋找任何額外的信息來肯定大小和位置就行了。然而事實上,咱們一直在運行佈局代碼來獲取其餘信息。
處於圖中這個節點中,若是出於某種緣由咱們能夠跳到樹的另外一部分嗎?不能夠,這是一個毀滅性操做。
至於線程安全,還記得最開始咱們瞭解的渲染流水線吧?咱們遍歷 layout 樹,還對它進行標註,而後傳遞給繪製階段。當咱們完成全部任務準備生成下一幀內容時,會從上次使用的 layout 樹開始,根據已改變的內容來更新它。這裏是沒有什麼是線程安全的,可能有多個線程修改它。
對於以上兩個問題,相應有兩個解決方案。針對組合問題,解決方案是 CSS 定製佈局即 Houdini,這意味着能夠在元素上設置特定的 CSS 屬性,而後定義一個 JavaScript 函數,該函數負責佈局該元素及子樹。在常規佈局過程當中,咱們會暫停而後去調用 JavaScript 函數,傳給它一組佈局元素所須要的信息,函數將消費它。這裏不會講太多 Houdini
的細節,你們有興趣能夠自行研究。
針對第二個問題的解決方案是 Layout NG
,這其實是對如何完成佈局的全盤反思。Layout NG
有兩個特性,一是它使用約束驅動的佈局,輸入一個子樹來進行佈局,咱們傳遞給它全部它所須要的在子樹中進行佈局的信息,並且它根本不看子樹的外面。實現這一點也並不容易,經過在中強制封裝,咱們讓底層佈局代碼更容易實現剛纔提到的 CSS 定製佈局。第二個特性是,輸入(layout 樹)與輸出(fragment 樹)的樹都是不可變對象,咱們每次都建立一個新的佈局樹,一旦咱們建立了它,該樹就不可變了,咱們並非在這個輸入樹上進行註釋,而是複製它,並用新的替換子樹來改變子樹,咱們將擁有佈局樹的全新副本。
這兩個特性的實現將使得佈局方面的各類強力優化成爲可能。這一項目尚屬早期,第一階段預計在今年年末、明年年初發布。