天天看點

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

作者|章志堅(皓黯)

出品|阿裡巴巴新零售淘系技術部

導讀:最近閑魚技術團隊在嘗試使用集團 DinamicX 的 DSL ,通過下發 DSL 模闆實作 Flutter 端的動态化模闆渲染。在解決了性能方面的問題後,又面臨了一個新的挑戰——渲染一緻性。如何在不降低渲染性能的前提下,大幅度提升 Flutter 與 Native 之間的渲染一緻性呢?

思路

在初版渲染架構設計當中,我們以 Widget 為中心,采用了組合的方案來完成 DSL 到 Widget 的轉化。這方面的工作在早期還算比較順利,然而随着模闆複雜度的增加,逐漸出現了一些 Bad Case 。

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

分析了這些 Bad Case 後發現,在初版渲染架構下,無法徹底解決這些 Bad Case,原因主要為以下兩點:

  1. 我們使用了 Stack 來代表 FrameLayout,Column/Row 來代表 LinearLayout,它們看似功能相似,實則内部實作差異較大,使用過程中引起了很多難以解決的 Bad Case。
  2. 初版嘗試通過自定義 Widget 對 DSL 的布局理念做了初步的了解,但是未能做到完全對齊,使得 Bad Case 無法得到系統性解決。

如需從根本上解決這些問題,需要重新設計一套新的渲染架構方案,完全了解并對齊 DSL 的布局理念。

新版渲染架構設計

由于 DinamicX 的 DSL與Android XML 十分相似,是以我們将以 Android 的 Measure 機制來介紹其布局理念。相信很多同學都明白,在 Android 的 Measure 機制中,父 View 會根據自身的 MeasureSpecMode 和子 View 的 LayoutParams 來計算出子 View 的 MeasureSpecMode ,其具體計算表格如下(忽略了 MeasureSpecMode 為 UNSPECIFIED 的情況):

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

我們可以基于上面這個表格,計算出每個 DSL Node 的寬/高是 EXACTLY 還是 AT_MOST 的。Flutter 若想了解 DynamicX DSL ,就需要引入 MeasureSpecMode 的概念。由于初版渲染架構以 Widget 為中心,難以引入 MeasureSpecMode 的概念,因而需要以 RenderObject 為中心,對渲染架構做重新的設計。

基于 RenderObject 層,設計了一個新的渲染架構。在新的渲染架構中,每一個 DSL Node 都會被轉化為 RenderObject Tree 上的一顆子樹,這棵子樹主要由三部分組成。

  • Decoration 層: Decoration 層用于支援背景色、邊框、圓角、觸摸事件等,這些我們可以通過組合方式實作。
  • Render 層:Render 層用于表達 Node 在轉化後的布局規則與尺寸大小。
  • Content 層:Content 層負責顯示具體内容,對于布局控件來說,内容就是自己的 children ,而對于非布局控件如 TextView、ImageView 等,内容将采用 Flutter 中的 RenderParagraph、RenderImage 來表達。
做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

Render 層為我們新版渲染架構中的核心層,用于表達 Node 轉化後的布局規則與尺寸大小,對于了解 DSL 布局理念起到了關鍵性作用,其類圖如下:

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

DXRenderBox 是所有控件 Render 層的基類,其派生了兩個類: DXSingleChildLayoutRender 和 DXMultiChildLayoutRender 。其中 DXSingleChildLayoutRender 是所有非布局控件 Render 層的基類,而 DXMultiChildLayoutRender 則是所有布局控件 Render 層的基類。

對于非布局控件來說,Render 層隻會影響其尺寸,不影響内部顯示的内容,是以理論上 View、ImageView、Switch、Checkbox 等控件在 Render 層的表達都是相同的。

DXContainerRender 就是用于表達這些非布局控件的實作類。這裡TextView 由于有 maxWidth 屬性會影響其尺寸以及需要特殊處理文字垂直居中的情況,因而單獨設計了 DXTextContainerRender 。

對于布局控件來說,不同的布局控件代表着不同的布局規則,是以不同的布局控件在Render層會派生出不同的實作類。DXLinearLayoutRender 和 DXFrameLayoutRender 分别用于表達 LinearLayout與FrameLayout 的布局規則。

新版渲染架構實作

完成新版渲染架構設計之後,我們可以開始設計基類 DXRenderBox 了。對于DXRenderBox 來說,我們需要實作它在 Flutter Layout 中非常關鍵的三個方法:sizedByParent、performResize 和 performLayout。

▐ Flutter Layout 的原理

我們先來簡單回顧一下 Flutter Layout 的原理,由于之前已有諸多文章介紹過Flutter Layout 的原理,這次就直接聚焦于 Flutter Layout 中用于計算 RenderObject 的 size 的部分。

在 Flutter Layout 的過程中,最為重要的就是确定每個 RenderObject 的 size ,而 size 的确定是在 RenderObject 的 layout 方法中完成的。 layout 方法主要做了兩件事:

  1. 确定目前 RenderObject 對應的 relayoutBoundary
  2. 調用 performResize 或 performLayout 去确定自己的 size

    為了友善讀者閱讀将 layout 方法做了簡化,代碼如下:

Constraintsget constraints => _constraints;
Constraints _constraints;

boolget sizedByParent => false;
void layout(Constraints constraints, { bool parentUsesSize = false}) {
//計算relayoutBoundary
......
//layout
    _constraints = constraints;
if(sizedByParent) {
        performResize();
}
    performLayout();
......
}
}           

可以說隻要掌握了 layout 方法,那麼對于 Flutter Layout 的過程也就基本掌握了。接下來我們來簡單分析一下 layout 方法。

參數 constraints 代表了 parent 傳入的限制,最後計算得到的 RenderObject 的size必須符合這個限制。參數 parentUsesSize 代表 parent 是否會使用 child 的 size ,它參與計算 repaintBoundary,可以對 Layout 過程起到優化作用。

sizedByParent 是 RenderObject 的一個屬性,預設為 false ,子類可以去重寫這個屬性。顧名思義,sizedByParent 表示 RenderObject 的 size 的計算完全由其parent 決定。換句話說,也就是 RenderObject 的 size 隻和 parent 給的 constraints 有關,與自己 children 的 sizes 無關。

同時,sizedByParent 也決定了 RenderObject 的 size 需要在哪個方法中确定,若 sizedByParent 為 true ,那麼 size 必須得在 performResize 方法中确定,否則 size 需要在 performLayout 中确定。

performResize 方法的作用是确定 size ,實作該方法時需要根據 parent 傳入的constraints 确定 RenderObject 的 size。

performLayout 則除了用于确定 size 以外,還需要負責周遊調用 child.layout 方法對計算 children 的 sizes 和 offsets。

▐ 如何實作 sizedByParent

sizedByParent 為 true 時,表示 RenderObject 的 size 與 children 無關。那麼在我們的 DXRenderBox 中,隻有當 widthMeasureMode 和 heightMeasureMode 均為 DX_EXACTLY 時,sizedByParent 才能被設為 true。

代碼中的 nodeData 類型為 DXWidgetNode ,代表上文中提到的 DSL Node ,而 widthMeasureMode 和 heightMeasureMode 則分别代表 DSL Node 的寬與高對應的 MeasureSpecMode。

abstractclassDXRenderBoxextendsRenderBox{

DXRenderBox({@requiredthis.nodeData});
DXWidgetNode nodeData;

@override
boolget sizedByParent {
return nodeData.widthMeasureMode == DXMeasureMode.DX_EXACTLY &&
            nodeData.heightMeasureMode == DXMeasureMode.DX_EXACTLY;
}

......
}           

▐ 如何實作performResize

隻有 sizedByParent 為 true 時,也就是 widthMeasureMode 和 heightMeasureMode 均為 DXEXACTLY 時,performResize 方法才會被調用。而若 widthMeasureMode 和 heightMeasureMode 均為 DXEXACTLY,則證明 nodeData 的寬高要麼是具體值,要麼是 matchparent,是以在 performResize 方法裡隻需要處理寬/高為具體值或 matchparent 的情況即可。寬/高有具體值取具體值,沒有具體值則表示其為 match_parent,取 constraints 的最大值。

abstractclassDXRenderBoxextendsRenderBox{
......

@override
void performResize() {
double width = nodeData.width ?? constraints.maxWidth;
double height = nodeData.height ?? constraints.maxHeight;
        size = constraints.constrain(Size(width, height));
}

......
}           

非布局空間如何實作 performLayout

DXRenderBox 作為所有控件Render層的基類,無需實作 performLayout 。不同的 DXRenderBox 的子類對應的 performLayout 方法是不同的,這個方法也是 Flutter 了解 DSL 的關鍵。接下來以 DXSingleChildLayoutRender 為例子來說明 performLayout 的實作思路。

DXSingleChildLayoutRender 的主要作用是确定非布局控件的大小。比如一個 ImageView 具體有多大,就是通過它來确定的。

abstractclassDXSingleChildLayoutRenderextendsDXRenderBox
withRenderObjectWithChildMixin<RenderBox> {

@override
void performLayout() {
BoxConstraints childBoxConstraints = computeChildBoxConstraints();
if(sizedByParent) {
      child.layout(childBoxConstraints);
} else{
      child.layout(childBoxConstraints, parentUsesSize: true);
      size = defaultComputeSize(child.size);
}
}

......
}           

首先,我們先計算出 childBoxConstraints。接着判斷其是否是 sizedByParent 。如果是,那麼其 size 已經在 performResize 階段計算完成,此時隻需要調用 child.layout 方法即可。否則,需要在調用 child.layout 時将 parentUsesSize 參數設定為 true,通過 child.size 來計算其 size。

可是該如何根據 child.size 來計算 size 呢?

Size defaultComputeSize(Size intrinsicSize) {
double finalWidth = nodeData.width ?? constraints.maxWidth;
double finalHeight = nodeData.height ?? constraints.maxHeight;

if(nodeData.widthMeasureMode == DXMeasureMode.DX_AT_MOST) {
        finalWidth = intrinsicSize.width;
}

if(nodeData.heightMeasureMode == DXMeasureMode.DX_AT_MOST) {
        finalHeight = intrinsicSize.height;
}
return constraints.constrain(Size(finalWidth,finalHeight));
}           

如果寬/高所對應的 measureMode 為 DXEXACTLY,那麼最終寬/高則有具體值取具體值,沒有具體值則表示其為 matchparent,取 constraints 的最大值。

如果寬/高所對應的 measureMode 為 DX_ATMOST ,那麼最終寬/高取 child 的寬/高即可。

布局空間如何實作 performLayout

布局控件在 performLayout 中除了需要确定自己的 size 以外,還需要設計好自己的布局規則。以 FrameLayout 為例來說明一下布局控件的 performLayout 該如何實作。

classDXFrameLayoutRenderextendsDXMultiChildLayoutRender{
@override
void performLayout() {
BoxConstraints childrenBoxConstraints = computeChildBoxConstraints();
double maxWidth = 0.0;
double maxHeight = 0.0;
//layout children
    visitDXChildren((RenderBox child,int index,DXWidgetNode childNodeData,DXMultiChildLayoutParentData childParentData) {
if(sizedByParent) {
        child.layout(childrenBoxConstraints,parentUsesSize: true);
} else{
        child.layout(childrenBoxConstraints,parentUsesSize: true);
        maxWidth = max(maxWidth,child.size.width);
        maxHeight = max(maxHeight,child.size.height);
}
});
//compute size
if(!sizedByParent) {
      size = defaultComputeSize(Size(maxWidth, maxHeight));
}
//compute children offsets
    visitDXChildren((RenderBox child,int index,DXWidgetNode childNodeData,DXMultiChildLayoutParentData childParentData) {
Alignment alignment = DXRenderCommon.gravityToAlignment(childNodeData.gravity ?? nodeData.childGravity);
      childParentData.offset = alignment.alongOffset(size - child.size);
});
}
}           

FrameLayout 的布局過程一共可分為3部分

  1. layout 所有的 children,如果 FrameLayoutRender 不是 sizedByParent ,需要同時計算所有children 的最大寬度與最大高度,用于計算自身 size 。
  2. 計算自身size,其中計算方案defaultComputeSize詳見上一小節
  3. 将gravity轉化為alignment,計算所有children的offsets。

    看了FrameLayout的布局過程,是否覺得非常簡單呢?不過需要指出的是,上述FrameLayoutRender的代碼會遇到一些Bad Case,其中比較經典的問題就是FrameLayout的寬/高為matchcontent,而其children的寬/高均為matchparent。這種情況在Android下會對同一個child進行"兩次measure",那麼在Flutter下該如何實作呢?

Flutter如何實作兩次measure的問題?

我們先來看一個例子:

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

上圖的LinearLayout是一個豎向線性布局,width被設為了matchcontent,它包含了兩個TextView,width均為matchparent,那麼這個例子中,整個布局的流程應該是怎樣的呢。

首先需要依次measure兩個TextView的width,MeasureSpecMode為AT_MOST,簡單來說,就是問它們具體需要多寬。接着LinearLayout會将兩個TextView需要的寬度的最大值設為自己的寬度。最後,對兩個TextView進行第二次measure,此時MeasureSpecMode會被改為Exactly,MeasureSpecSize為LinearLayout的寬度。

而常見的Flutter的layout過程為以下兩種:

  • 先在performResize中計算自身size,再通過 child.layout确定children sizes
  • 先通過child.layout确定children sizes,再根據children sizes計算自身size

以上方案均不能滿足例子中我們想要的效果,需要找到一個方案,在調用child.layout之前,便能知道child的寬高。最後我們發現,getMinIntrinsicWidth、getMaxIntrinsicWidth、getMinIntrinsicHeight、getMaxIntrinsicHeight四個方法能夠滿足我們。以getMaxIntrinsicHeight為例,來講講這些方法的用途。

double getMaxIntrinsicWidth(double  height) {
return _computeIntrinsicDimension(_IntrinsicDimension.maxWidth, height, computeMaxIntrinsicWidth);
}           

getMaxIntrinsicWidth接收一個參數height,用于确定當height為這個值時maxIntrinsicWidth應該是多少。這個方法最終會通過computeMaxIntrinsicWidth方法來計算maxIntrinsicWidth,計算結果會被儲存。如果需要重寫,不應該重寫getMaxIntrinsicWidth方法,而是應該重寫computeMaxIntrinsicWidth方法。需要注意的是這些方法并非輕量級方法,隻有在真正需要的時候才可使用。

或許你不禁要問,這些方法計算出來的寬高準嗎?實際上每個 RenderBox 的子類都需要保證這些方法的正确性,比如用于展示文字的 RenderParagraph 就實作了這些 compute 方法,是以得以在RenderParagraph沒被layout之前,擷取其寬度。

我們設計的 Render 層中的類也得實作 compute 方法,這些方法實作起來并不複雜,還是以 DXSingleChildLayoutRender 為例子來說明該如何實作這些方法。

@override
double computeMaxIntrinsicWidth(double height) {
if(nodeData.width != null) {
return nodeData.width;
}
if(child != null) return child.getMaxIntrinsicWidth(height);
return0.0;
}           

上述代碼比較簡單,不再贅述。

那麼我們再簡單看一下例子中的問題——先通過 child.getMaxIntrinsicWidth來計算每個 child 需要的 width。接着将這些寬度的最大值确定 LinearLayout 的width,最後通過 child.layout 對每個孩子進行布局,傳入的 constraints 的maxWidth 和 minWidth 均為 LinearLayout的width。

效果

新版渲染架構使得Flutter能了解并對齊 DSL 的布局理念,系統性解決了之前遇到的 Bad Case ,為 Flutter 動态模闆方案帶來了更多的可能性。

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

對新老版本的渲染性能做了測試對比,在新版渲染架構下通過頁面渲染耗時對比以及 FPS 對比可以發現,動态模闆的渲染性能得到了進一步的提升。

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

後續展望

在渲染架構更新之後,我們徹底解決了之前遇到的 Bad Case ,并為系統性分析解決這類問題提供了有力的抓手,還進一步提升了渲染性能,這讓 Flutter 動态模闆渲染成為了可能。未來我們将繼續完善這套解決方案,做到技術賦能業務。

參考文獻

https://flutter.dev/docs/resources/inside-flutter https://www.youtube.com/watch?v=UUfXWzp0-DU https://www.youtube.com/watch?v=dkyY9WCGMi0

We are hiring

閑魚團隊是Flutter+Dart FaaS前後端一體化新技術的行業領軍者,就是現在!用戶端/服務端java/架構/前端/品質工程師面向社會招聘,base杭州阿裡巴巴西溪園區,一起做有創想空間的社群産品、做深度頂級的開源項目,一起拓展技術邊界成就極緻!

*投喂履歷給小閑魚→[email protected]

掃描下方二維碼,關注「淘系技術」擷取更多技術幹貨

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?思路新版渲染架構設計新版渲染架構實作Flutter如何實作兩次measure的問題?效果後續展望

繼續閱讀