[Flutter 渲染優化系列] Flutter 渲染性能問題分析


作者:易旭昕

原文鏈接:https://zhuanlan.zhihu.com/p/354631257

本文由作者授權發佈。

易老師寫了很多篇關於 Flutter 渲染引擎的文章,講的非常深入,我從中學到了很多,昨天很有幸的加到易老師微信,表達了一番崇敬之情,易老師人非常好,也非常謙遜。

最後表達一點小小的心意,發了個紅包,不管怎麼說,學到了很多知識,知識是無價的,不過易老師並沒有收,大家也可以到易老師到博客中看看其他文章,點贊、轉發也是一種支持,後面我也會繼續分享易老師的文章。

正文

我在Flutter vs Chromium 動畫渲染的對比分析一文中對 Flutter 和 Web (Chromium) 的各種動畫的理論性能優劣進行了分析,其中一個主要結論是,由於慣性滾動處理機制和光柵化機制的不同,Web (Chromium) 的慣性滾動動畫性能理論上要遠遠優於 Flutter。而在一些已經上線的使用 Flutter 的業務中,業務方也持續給我們反饋了這些業務在中低端 Android 手機上存在比較嚴重的慣性滾動性能問題:

  1. 業務 A 的頁面較爲簡單,但是在低端手機上平均幀率在 40 ~ 50 之間,中端手機在 50 ~ 55 之間,低端機存在較爲明顯的卡頓問題;
  2. 業務 B 的頁面比較複雜,業務邏輯也較爲複雜,在低端手機上平均幀率更是低到最低 30 多幀(35 ~ 45 之間),中端手機也是在 50 左右,並且存在較爲頻繁的長時間卡頓,低端機存在比較嚴重的卡頓問題,中端機也不太流暢;

而以我們長期的經驗數據,對於 Web 來說,即使在低端手機上,較爲複雜的頁面慣性滾動幀率一般也在 50 以上,也較少長時間的卡頓,達到基本流暢的水平。並且剛好業務 B 有完全一樣的 Native 版本,它對比 Flutter 版本,幀率普遍高了 5 ~ 10 幀左右。

所以雖然我們沒有找到同一個頁面的三個不同版本進行嚴格的比對,但是基於上述的測試數據和我們長期的經驗,很容易得出結論是,在慣性滾動的性能上:

Web (Chromium) > Native (Android) > Flutter (Android)

我們在不同設備上對上述業務頁面在慣性滾動過程中進行 trace 的抓取,結合 Flutter 的代碼對 trace 文件進行分析,瞭解 Flutter 渲染流水線在慣性滾動過程中各個環節的調度,瞭解各個環節的可能耗時和哪些環節可能成爲性能瓶頸。在分析的過程中,我們對 Flutter 的渲染機制有了更深入的瞭解,這篇文章就是對比 Web (Chromium) 和 Native (Android),對 Flutter 的渲染性能問題進行深入分析,特別是分析慣性滾動性能糟糕的原因

\1. 這裏的幀率數據給的是一個範圍是因爲我們使用了幾種不同的滾動速度進行測試,一般來說滾動速度越快,平均幀率就越低 \2. iPhone 基本不存在所謂的低端機,iOS 整體表現都還可以,不同實現的差異不大,所以我們目前主要的測試和優化都是在 Android 上進行

寫在前面的結論

Flutter 有很多優點,特別是對於開發者來說,跨平臺多端支持,豐富的 UI 組件庫和交互效果,聲明式 UI,React 的更新方式,Hot-reload 提高開發效率等等。雖然它在渲染性能上有不少缺陷,但是某種程度上,某些缺陷也是爲了實現更高層次的設計目標而不得不承受的結果。

比如 Dart 語言原生對異步編程有良好的支持,應用開發者不需要去編寫複雜和容易出問題的多線程代碼,就可以有效地避免主線程長時間阻塞,編寫出性能良好的 UI。但是在慣性滾動這樣對性能要求非常高場景下,可能幾毫秒的阻塞都會導致掉幀,缺少真正的多線程編程能力某種程度就變成了一種阻礙(Android 上你甚至可以在其它線程對 View 做非 UI 直接相關的操作)。

又比如使用 Immutable Widget 作爲 UI Configuration 的設計是聲明式 UI 和 Hot-reload 的基礎,但還是會引入額外的開銷和喪失足夠的靈活性,應用無法直接控制 UI 組件的生命週期,無法直接控制 UI 組件的佈局和繪製,這同樣妨礙了慣性滾動的性能優化。

而對我們內核團隊來說,要做的就是在理解 Flutter 這些缺陷的同時,去研究是否存在有效地進行局部改進,或者從其它設計層面上對某些缺陷進行規避的方法,讓應用開發者既可以充分利用 Flutter 的優勢,又不用過於擔心它存在的問題。

總的來說下半年的工作目前看來還是取得了不錯的成果,也基本實現了讓 Flutter 慣性滾動性能對標原生的目標,下圖比較直觀地展示了我們優化的結果。

img

這裏電影幀是指 1000 / 24 約 40毫秒,2個電影幀 / min 是指連續滾動一分鐘內出現超過 80 毫秒卡頓的次數

Web (Chromium) vs Flutter

Web (Chromium) 在慣性滾動上是有非常明顯的機制優勢的,這跟 Web 渲染引擎爲了適應 Web 頁面的高複雜度,高不確定性有關,甚至某種程度上犧牲了一些渲染效果和其它動畫的渲染性能。Web (Chromium) 在慣性滾動上的優勢主要體現在以上兩方面:

  1. Chromium 有完整獨立的合成器驅動慣性滾動動畫的運行,有獨立的合成線程,慣性滾動動畫的更新和主線程更新 DOM 樹是不同步的,主線程運行 JS,Build & Layout 不會阻塞合成線程;
  2. Chromium 的分塊異步光柵化機制一方面減少了慣性滾動動畫過程中圖層的重複光柵化,另一方面光柵化不會阻塞合成線程的合成輸出;

對比 Web (Chromium),Flutter 在上述兩方面都存在比較明顯的劣勢:

img

Flutter 需要依賴於 Relayout 來驅動慣性滾動動畫,滾動容器內的元素在滾動過程中每一幀都需要 Relayout,不過這個一般耗時不高。Flutter 的無限長列表一般都採用 Lazy Build 的方式生成列表單元,當列表單元接近可見區域的時候,框架才調用應用提供的 Builder 生成列表單元的 Widget 樹並進行佈局,新掛載的列表單元的 Build & Layout 通常耗時較長,在上述業務頁面中,可能耗費 10 毫秒以上,甚至幾十毫秒,特別是單幀內需要 Build 多個單元的情況,它們是導致掉幀的主要原因。從上圖 trace 中我們很容易發現,正常速度滾動下,在 Flutter UI 線程 Frame 的階段,大部分情況下耗時不高,但是每幾幀就會出現一次耗時較長的 Frame,從上圖看耗時較長的 Frame 已經接近甚至超過一個 vsync 週期,滾動速度越快,出現耗時較長的 Frame 的頻率就越高,耗時也可能越長,它的耗時主要就來自新掛載列表單元的 Build & Layout。

Flutter 採用的以直接光柵化爲主,間接光柵化爲輔的同步光柵化機制,在合成輸出過程中進行光柵化,光柵化的耗時會直接影響動畫的性能。以實際業務爲例子:

  1. 業務 A 的頁面較爲簡單,光柵化耗時大部分在 3 ~ 5 毫秒之間,除了偶爾波動較高外,基本沒有造成阻塞,所以業務 A 的大部分掉幀都是 Flutter UI 線程的 Frame 耗時較高導致;
  2. 業務 B 的頁面比較複雜,光柵化耗時大部分在 7 ~ 10 毫秒之間,偶爾波動超過 10 毫秒,所以部分掉幀主要是光柵化導致的;
  3. 實際上我們還碰到一個頁面因爲大範圍使用 Backdrop Filter 導致光柵化耗時非常高,在低端機上只有 10 ~ 20幀,不過這個可以在應用層面做一些優化來避免;

總的來說,Flutter 在慣性滾動過程的掉幀大部分都來自 Flutter UI 線程的阻塞,新掛載列表單元的 Build & Layout 耗時過長是主要原因。但是對於一些比較複雜的頁面,光柵化耗時較長也是一個導致掉幀的原因。

我們在 Chromium 光柵化改造 - 混合光柵化 對比了不同光柵化機制在合成輸出過程中的光柵化+合成輸出的耗時,異步光柵化機制在這方面會有明顯的優勢,這也是我們在 U4 4.0 上採用了混合光柵化的原因

Flutter 雖然提供了 KeepLive 機制用於避免列表單元滾出可見區域被回收,重新滾入可見區域又重新 Rebuild & Relayout,但是 KeepLive 機制並不適用於第一次顯示的列表單元,並且在無限長列表場景很容易造成內存爆炸,適用場景不多

Native (Android) vs Flutter

如果說 Web (Chromium) 因爲機制的原因,慣性滾動性能明顯優於 Flutter,這個比較容易理解。那麼 Native (Android) 在機制上其實跟 Flutter 是比較類似的,爲什麼它的性能也會優於 Flutter 呢?

Android 無限長列表一般使用 RecyclerView 實現,而 RecyclerView 支持子 View 樹級別的複用,使得新掛載的列表單元在 RecyclerView 的支持下,只需要更新複用的子 View 樹的數據然後局部重排即可,耗時會大大少於 Flutter 整個列表單元的完整 Build & Layout,這是 Native (Android) 的無限長列表滾動更流暢的主要原因。不過除此以外,還有很多因素也會影響到 Flutter 的流暢度。

跟 Native 相比較,Flutter UI 線程會顯得更擁擠。Dart Isolate 的內存堆是隔離的,這點比較像 JavaScript,Isolate 之間的關係更像是多進程而不是多線程,導致了一些多線程優化很難實現。應用通常要註冊多個回調來處理外部傳入的數據或者事件,這些回調接收外部數據或者事件,進行處理後更新內部數據(Model),通常這些回調都需要在 UI 線程執行。如果它們集中頻繁地發生,即使單次耗時不高,也很容易造成 Flutter UI 線程的阻塞,簡單說就是這些非 UI 任務的頻繁執行可能會導致慣性滾動過程中 UI 任務的延遲,最終導致掉幀,但是 Dart Isolate 的限制,對內部數據的更新又必須在 UI 線程上進行。

大部分應用都是局部使用 Flutter 開發,需要跟 Native 進行混用,這就導致了應用很難使用 SurfaceView,而需要使用 TextureView。TextureView 會帶來一些額外的性能問題,除了更高的 GPU 開銷外,TextureView 的繪製機制也容易出現因爲調度的不合理而導致掉幀。

最後雖然 Android 和 Flutter 都是以直接光柵化爲主,間接光柵化爲輔的同步光柵化機制。但是將 Skia 作爲 UI 的光柵化引擎,比起爲 UI 專門定製的光柵化引擎可能還是存在一些缺陷:

  1. Skia GPU 光柵化的過程,涉及將通用的 2D 繪製指令轉換成一種接近 GPU 指令的內部形式,然後經過進一步優化後輸出最終的 GPU 指令,爲 UI 專門定製的光柵化引擎理論上可以緩存第一步的結果,減少每一幀光柵化的耗時;
  2. Skia 作爲一個通用的光柵化引擎,內部實現是線程無感的,而爲 UI 專門定製的光柵化引擎可以更容易使用多線程來將光柵化過程中部分 CPU 工作並行化,比如生成字型或者路徑頂點等任務;

不過我們沒有實際去比較兩者的光柵化性能差異,這裏只是一些理論分析。

TextureView 的調度問題更詳細的信息可以參考我的文章TextureView 的血與淚

應用層面優化和侷限性

針對 Flutter 的慣性滾動性能問題,不少應用也嘗試了各種優化方案,比如閒魚的方案就比較有代表性。針對新掛載列表單元的 Build & Layout 耗時過長,閒魚的優化方案是 Element 複用和分幀渲染。

Element 複用其實就是參考 RecyclerView 的子 View 樹複用,理論上可以避免重新創建列表單元的 Element 樹和 RenderObject 樹的時間開銷。但是對比 Native,仍然需要重新構建 Widget 樹,並把新的 Widget 樹跟舊的 Element 樹進行綁定,並通過 Element 樹去更新 RenderObject 樹。而 Native 則可以直接複用 View 樹,然後更新若干子 View 的數據即可,這部分的開銷仍然比優化過後的 Flutter 要低。

分幀渲染的思路是每個列表單元提供兩個版本的 Widget 樹,除了完整版,還有一個簡化版作爲佔位符。如果單幀內已經 Build 過一個完整版本的單元,在需要 Build 第二個單元時就只 Build 簡化的版本,這樣可以避免單幀內多個列表單元的 Build & Layout 疊加在一起造成更大的阻塞。它的侷限性是主要適用於列表單元較小,慣性滾動速度較快,一幀滾動會出現多個列表單元需要 Build & Layout 的場景,對避免更長時間的卡頓有一定作用。只是這個優化 Android Native 看起來也完全能做,並且因爲 Android 應用可以直接控制 View 是否參與佈局和繪製,理論上做起來也更簡單,效果也更好。

總的來說,Flutter 應用的一些優化,要不是 Native 本來就已經實現,並且效果更好;就是 Native 同樣也可以實現,而且實現起來更簡單,效果也更好,並且其它一些影響 Flutter 性能的因素在應用層面無法進行優化。

所以 Flutter 應用優化起來可能比 Native 更麻煩,最後的效果也還是比不上 Native。一個優化後的 Flutter 應用,比起一個優化後的 Native 應用,在慣性滾動上還是會有一定性能差距。

我們的優化嘗試

作爲一個引擎團隊,我們期望實現的目標是從框架和引擎層面對 Flutter 渲染流水線的方方面面進行優化,使應用在不需要改動或者極少量改動就能實現基本對標原生的慣性滾動流暢度,如果應用本身再進一步優化,甚至有可能獲得優於原生的效果。

我們嘗試了各式各樣的優化,包括:

  1. 優化線程的優先級設置,更好地保障渲染流水線的前臺線程,UI 和 Raster 線程不會因爲無法獲取到 CPU 調度而阻塞;
  2. 優化渲染流水線的 vsync 調度,減少一些不必要的耗時和空等;
  3. 優化渲染流水線針對 TextureView 繪製的調度,規避 TextureView 繪製機制的副作用;
  4. 重構渲染流水線的調度邏輯,通過更深的流水線深度來增加輸出的吞吐量,使得輸出更平穩連續;
  5. 優化一些佈局算法,減少佈局耗時;
  6. 優化新掛載列表單元的 Build & Layout 的調度,減少其成爲性能瓶頸的可能,比如說將新掛載單元的 Build 和 Layout 拆分到不同幀去執行;
  7. 優化光柵化性能,比如更好地支持客戶端使用類似 Web 開發的 Opacity Hack 的技巧,通過使用間接光柵化來減少光柵化耗時;

從目前來看,部分優化嘗試的效果還是十分明顯,有些優化的覆蓋面很廣,適用於幾乎所有的場景,而有些優化對特定場景效果比較好。總的來說,測試的業務頁面運行在我們優化過後的引擎,整體流暢度能夠明顯提升一個臺階,也基本實現了我們對標原生流暢度的目標。在後續的文章中,我會逐步介紹我們所做的一些優化,同時我們也會爭取將一些優化的代碼提交回社區。

Stay tune, my friends, stay tune...


你可能還喜歡


關注「老孟Flutter」
讓你每天進步一點點


本文分享自微信公衆號 - 老孟Flutter(lao_meng_qd)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章