如何在Flutter上实现高性能的动态模板渲染
背景
最近小組在嘗試使用一套阿里dinamicX的DSL,通過動態模板下發,實現Flutter端的動態化模板渲染;本來以為只是DSL到Widget的簡單映射和數據綁定,但實際跑起來的效果出乎意料的差,列表卡頓嚴重,幀率丟失嚴重。這就讓我們不得不深入Flutter的Framework層,去了解Widget的創建、布局以及渲染的過程。
為什么Native可行的方案在Flutter效果這么差
在iOS和Android開發中,DSL到Native的方案其實并不陌生;Android中,我們就是通過編寫XML文件來描述頁面布局。Native的這種映射的方案,為什么在Flutter上,效果變得如此糟糕呢?
先通過一個簡單的示例來看一下dinamicX DSL的定義:
可以看到DSL的設計與Android中的XML很相似,在我們的DSL中,每個節點的width和height屬性,可以賦值兩種特殊意義的值:match_parent和match_content。
match_parent:當前節點大小,盡量撐開到父節點大小;
match_content:當前節點大小,盡量縮小到容納子節點大小;
在Flutter中,并沒有match_parent和match_content的概念。最初我們的想法很簡單,在Widget的build方法中,如果屬性是match_parent,就不斷向上遍歷,直到找到一個父節點有確定的寬高值為止;如果是match_content,遍歷所有的子節點,獲取子節點大小;一旦子節點存在match_content屬性,會遞歸調用下去。
表面上看,做好每個節點的寬高計算的緩存,雖然達不到一次性線性布局,這樣的開銷也并不是很大。但我們忽略掉了一個很重要的問題:Widget是immutable的,只是包含了視圖的配置信息,是非常輕量級的。在Flutter中,Widget會被不斷的創建銷毀,這會導致布局計算非常的頻繁。
要解決這些問題,單單處理Widget是不夠的,需要Element以及RenderObject上做更多的處理,這也就是我們為什么要考慮自定義Widget的原因。
接下來通過源碼來了解Flutter中Widget的build、layout以及paint相關的邏輯。
認識三棵樹
我們通過一個簡單的Widget——Opacity來了解一下Widget、Element、RenderObject。
Widget
在Flutter中,萬物皆是Widget,Widget是immutable的,只是包含了視圖的配置信息的描述,是非常輕量級的,創建和銷毀的開銷比較小。
Opacity繼承自RenderObjectWidget,其定義了兩個比較關鍵的函數:
RenderObjectElement createElement();RenderObject createRenderObject(BuildContext context);這正是我們要找的Element和RenderObject!這里只是定義了創建的邏輯,具體調用的時機我們繼續往下看。
Element
在SingleChildRenderObjectWidget可以看到創建了SingleChildRenderObjectElement對象。
Element是Widget的抽象,在Widget初始化的時候,調用Widget.createElement創建,Element持有Widget和RenderObject;BuildOwner通過遍歷Element Tree,根據是否標記為dirty,構建RenderObject Tree;在整個視圖構建過程中,起到了串聯Widget和RenderObject的作用。
RenderObject
Opacity的createRenderObject函數創建了RenderOpacity對象,RenderObject真正提供給Engine層渲染所需要的數據,RenderOpacity的Paint方法中找到了真正繪制的地方:
void paint(PaintingContext context, Offset offset) {if (child != null) {...context.pushOpacity(offset, _alpha, super.paint);}}通過RenderObject,我們可以處理layout、painting以及hit testing。這是我們在自定義Widget處理最多的事情。RenderObject只是定義了布局的接口,并未實現布局模型,RenderBox為我們提供了2D笛卡爾坐標系下的Box模型協議定義,大部分情況下,都可以繼承于RenderBox,通過重載實現一個新的layout實現,paint實現,以及點擊事件處理等;
Flutter在Layout過程中的優化
Flutter采用一次布局的方式,O(N)的線性時間來做布局和繪制。
如上圖所示,在一次遍歷中,父節點調用每個子節點的布局方法,將約束向下傳遞,子節點根據約束,計算自己的布局,并將結果傳回給父節點;
RelayoutBoundary優化
當一個節點滿足如下條件之一,該節點會被標記為RelayoutBoundary,子節點的大小變化不會影響到父節點的布局:
- parentUsesSize = false:父節點的布局不依賴當前節點的大小
- sizedByParent = true:當前節點大小由父節點決定
- constraints.isTight:大小為確定的值,即寬高的最大值等于最小值
- parent is not RenderObject:如果父節點不是RenderObject,子節點layout變化不需要通知父節點更新
RelayoutBoundary的標記,子節點大小變化,不會通知父節點重新layout,重新paint,從而提高效率。
Element更新優化
為什么Widget頻繁創建銷毀不會影響渲染性能呢?
Element定義了updateChild的方法,最早在Element被創建,Framework調用mount的時候,以及RenderObject被標記為needsLayout執行RenderObject.performLayout等場景,會調用Element的updateChild方法;
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {...if (child != null) {...if (Widget.canUpdate(child.widget, newWidget)) {...child.update(newWidget);...} } }對于child和newWidget都不為空的情況,通過Widget.canUpdate來判斷當前child Element是否可以更新而非重現創建的方式update。
static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType&& oldWidget.key == newWidget.key;}我們可以看到Widget.canUpdate的定義,通過runtimeType和key比較來判斷;如果可以更新,更新Element子節點;否則deactivate子節點的Element,根據newWidget創建新的Element。
我們如何自定義Widget
第一個版本的設計
在第一個版本的設計中,我們考慮的比較簡單,所有的組件都繼承與Object,實現一個build方法,根據DSL轉換的nodeData設置Widget的屬性:
我們用一個簡單的例子來看,我們以最壞的情況來考慮,第一個節點都是match_content屬性,每一次Widget創建,我們需要的布局計算:
這樣每一次Widget更新,頂部節點的大小計算,都要深度遍歷整個樹。如果Widget其中一個節點更新,又會怎樣呢?
答案是全部重新計算一遍,因為Widget是immutable的,在不斷重新創建銷毀。在最壞情況,會達到O(N2),可想而知一個長列表會表現如何。
第二個版本的設計
第二個版本,我們選擇自定義Widget、Element以及RenderObject;下面是我們一部分組件的類圖。
其中虛線框內是我們自定義的Widget組件。從上面的圖可以看出,我們自定義的Widget大致分為三種類型:
- 只能作為葉子節點的Widget:如Image、Text,繼承自CustomSingleChildLayout;
- 可以設置多個子節點的Widget:如FrameLayout、LinearLayout,繼承自CustomMultiChildLayout;
- 可滾動的列表類型的Widget:如ListLayout、PageLayout,繼承自CustomScrollView;
在自定義的RenderObject中,對于點擊事件以及paint方法,并未做特殊處理,都交由組合的Widget處理。
@overridebool hitTestChildren(HitTestResult result, {Offset position}) {return child?.hitTest(result, position: position) ?? false;}@overridevoid paint(PaintingContext context, Offset offset) {if (child != null) context.paintChild(child, offset);}如何處理match_content
當前節點的寬高設置為match_content,需要先計算子節點的大小,然后再計算當前節點的大小。
在實現自定義的RenderObject中,我們需要重寫performLayout方法;performLayout方法中,主要的需要做的事:
下面以一個child的情況為例(如:Padding),在RenderObject中,對于match_content屬性的節點,在調用child layout方法時,將parentUsesSize設置為true;然后size根據child.size設置。
這樣做的一個好處,當child的大小變化的時候,自動會將parent設置為needLayout,parent由于被標記為needLayout,會在當前Frame的Pipline中重新layout、paint。當然這樣也會帶來性能的損耗,這一點需要特別注意。
@overridevoid performLayout() {assert(callback != null);invokeLayoutCallback(callback);if (child != null) {child.layout(constraints, parentUsesSize: true);size = constraints.constrain(child.size);} else {size = constraints.biggest; }多child的情況,可以參考RenderSliverList的內部實現。
如何處理match_parent
如果當前節點的寬高設置為match_parent,盡量擴充到父節點大小;這種情況下,在Constraints向下傳遞的時候,根據父節點的約束,無需子節點計算,就已經知道自己的大小;在RenderObject中為我們提供了一個屬性sizedByParent,默認為false,如果屬性設置為match_parent,我們會給當前RenderObject的sizedByParent設置為true;這樣在Constraints向下傳遞的時,子節點已經知道自己的大小,無需layout計算,在性能上有所提升。
在RenderObject中,當sizedByParent設置為true,需要重載performResize方法:
@overridevoid performResize() {size = constraints.biggest;}這里需要注意的一點,這種情況下,在重載performLayout方法時,不要再設置size的大小。
如果綁定的數據發生變化,改變sizedByParent之后,確保調用markNeedsLayoutForSizedByParentChange方法,將當前節點以及他的父節點設置為needsLayout,重新計算布局,重新繪制。
前后方案對比
在第二個版本的設計中,一個Widget渲染,需要怎樣一個計算過程呢呢?
相同的場景,在RenderObject中,通過performLayout方法,將Constraints向下傳遞,child的size計算,并且向上傳遞,最終一次遍歷就可以完成整個樹的layout計算。
如果是上面更新的場景又會如何呢?
根據我們上面講的Element更新過程以及RenderObject的RelayoutBoundary優化,可以看出,有新的Widget屬性變化,Element Tree無需重建,更新當前Element節點,RenderObject在RelayoutBoundary的優化下,只需要更少的layout計算。
經過新方案的優化,長列表滑動的平均幀率從28提升到了50左右。
目前存在的問題
目前我們在自定義Widget的實現中,其實還是存在問題的。如果仔細看上面performLayout的實現,我們在調用每個child的layout方法的時候,parentUsesSize都設置為true;實際上只有當前節點屬性為match_content的時候,這才是有必要的。目前我們的處理過于簡單,導致RelayoutBoundary的優化沒有真正享受到。所以目前實際的情況是,每次Widget的更新,都會導致2N次的Layout計算。這也是幀率達不到Flutter頁面的其中一個原因,這也是我們接下來要解決的問題。
更多優化方向
經過一系列的優化之后,頁面的卡頓情況終于有所改善,卡頓不再特別明顯,但整體幀率仍然達不到Flutter頁面的效果。仍然需要對Flutter有更深入的理解,挖掘出過多性能優化的點,進一步做一些更精細化的優化。
ListView和ScrollView,在Flutter中都有做性能優化處理。但是對于FrameLayout、LieanrLayout這樣有多個child的layout,無法享受ListView提供的性能優化。我們是否可以借鑒ListView的ViewPort的概念,對于超出屏幕的部分,不去做layout、paint渲染。當然這需要考慮Engine層layer緩存等情況,需要后續進一步的研究。
另外在parentData存儲,增加數據緩存以減少數據綁定次數方面,以及List嵌套List等復雜情況的優化處理,也都需要不斷探索。
展望
目前我們實現了DSL到Widget的映射,這讓Flutter動態模板渲染成為了可能。DSL是一種抽象,XML只是其中的一種選擇,未來在不斷完善性能的同時,還會提升整個方案的抽象,能夠支持通用的DSL轉換,沉淀一套通用解決方案,更好的通過技術賦能業務。
DSL到Widget的轉換只是其中一環,從模板的編輯、本地驗證、CDN下發、灰度測試、線上監控等整個閉環,仍然有很多需要不斷打磨和完善的地方。
原文鏈接
本文為云棲社區原創內容,未經允許不得轉載。
總結
以上是生活随笔為你收集整理的如何在Flutter上实现高性能的动态模板渲染的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: NoSQL 数据库不应该放弃 Consi
- 下一篇: 2019双11,支付宝有哪些“秘密武器”