本文系翻譯整理的 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
回撥。
接下來便是開始執行渲染流水線了,如下圖,共有四個步驟:
-
style: 將 DOM 樹轉化為 layout 樹,遍歷 layout 樹為每一個節點標註其樣式資訊,然後將帶有樣式資訊的 layout 樹傳遞到下一階段
-
layout: 我們將再次遍歷 layout 樹,為節點標註其尺寸、位置資訊,至此我們已兩次對 layout 樹進行標註,然後將它傳遞給合成階段
-
composition setup: 在合成設定階段我們會確定需要繪製多少個合成層(
compositing layers
),以及它們的尺寸、位置、層疊順序等 -
paint: 繪製階段會獲取 layout 樹的標註以及在合成設定階段所記錄資訊,然後建立一個由原始繪圖命令組成的“顯示列表”,它會指示合成器如何進行畫素繪製。
在繪製階段的結尾,會由主執行緒切換到合成執行緒(即下圖追蹤器中的綠色區域),將光柵化工作切分成幾個“瓦片”,分配給幾個工作執行緒來進行。待光柵化完成,我們將進入 Chrome 合成器。這一過程會迴圈往復地執行下去。
以上便是關於渲染的簡單介紹,值得注意的一點是,主執行緒非常繁忙,所有動作都發生在主執行緒,指令碼在主執行緒執行,還負責了渲染和許多其它功能,因此主執行緒是非常擁擠的。經過多年的優化工作,我們發現一個非常有效的優化方式,就是把主執行緒的工作切分,交給其它執行緒處理。
第二部分:渲染的重要性與時下的難題
對於 Web 平臺來說,渲染是非常重要的。
一是因為,動態網頁的本質是接受使用者或指令碼生成的輸入,並將其轉化為視覺結果。渲染是這個過程的核心,因此無論你的頁面做的有多麼酷炫,如果渲染出了問題,使用者就不會有任何好的體驗。
其二,渲染是網頁效能的主要決定因素(感知的和實際的),渲染是無法中斷的,如果 JavaScript 執行太久頁面就會變得笨重,這當然會引起使用者注意。
其三,現代網頁是動態的——會不斷地修改內容,載入內容,進行動畫。為了跟上步伐,保證互動流暢,渲染程式碼必須是一等公民。
下面開始介紹我們在渲染程式碼中遇到的挑戰,以及為了解決這些問題我們正在著手進行的改進。
1. 滾動
正如前文所說,渲染是網頁效能的主要決定因素,而滾動體驗則是其重中之重。使用者對於滾動體驗是非常敏感的,滾動的體驗決定了其對頁面整體效能的感知,如果滾動體驗很糟糕,頁面再酷炫也拯救不了。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 部分的程式碼修改。這是一個非常艱難的過程,要各種不同的程式碼修復才能讓我們真正回到基線效能。
所以我也不得不重申,這塊程式碼真的很難處理,如果我們犯了任何錯誤,使用者都會立即發現,這些錯誤也會影響所有頁面。
接下來我們來了解一下關於繪製與合成我們所做的改進。
2. 繪製與合成
同滾動程式碼一樣,繪製與合成部分的程式碼也相當古老,大概已經有 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 版本,將合成設定移動到繪製後進行。
3. 佈局排版
佈局有兩個主要問題,第一個是 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 樹)的樹都是不可變物件,我們每次都建立一個新的佈局樹,一旦我們建立了它,該樹就不可變了,我們並不是在這個輸入樹上進行註釋,而是複製它,並用新的替換子樹來改變子樹,我們將擁有佈局樹的全新副本。
這兩個特性的實現將使得佈局方面的各種強力優化成為可能。這一專案尚屬早期,第一階段預計在今年年底、明年年初發布。