天天看點

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?Flutter動态模闆渲染架構更新

Flutter動态模闆渲染架構更新

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

挑戰與思路

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

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?Flutter動态模闆渲染架構更新

​ 我們分析了這些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動态模闆渲染架構更新

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

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

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

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

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?Flutter動态模闆渲染架構更新

​ 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方法做了簡化,代碼如下:

abstract class RenderObject {
  Constraints get constraints => _constraints;
  Constraints _constraints;
    
  bool get 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。

abstract class DXRenderBox extends RenderBox {

    DXRenderBox({@required this.nodeData});
    DXWidgetNode nodeData;

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

如何實作performResize

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

abstract class DXRenderBox extends RenderBox {
       ......
        
    @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具體有多大,就是通過它來确定的。

abstract class DXSingleChildLayoutRender extends DXRenderBox
    with RenderObjectWithChildMixin<RenderBox> {
    
  @override
  void performLayout() {
    BoxConstraints childBoxConstraints = computeChildBoxConstraints();
    if (sizedByParent) {
      child.layout(childBoxConstraints);
    } else {
      child.layout(childBoxConstraints, parentUsesSize: true);
      size = defaultComputeSize(child.size);
    }
  }
  
  ......
}           

​ 首先,我們先計算出childBoxConstraints。接着判斷DXSingleChildLayoutRender是否是sizedByParent。如果是,那麼DXSingleChildLayoutRender的size已經在performResize階段計算完成,此時隻需要調用child.layout方法即可。否則,我們需要在調用child.layout時将parentUsesSize參數設定為true,通過child.size來計算DXSingleChildLayoutRender的size。可是我們該如何根據child.size來計算DXSingleChildLayoutRender的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));
}           

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

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

布局控件如何實作performLayout

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

class DXFrameLayoutRender extends DXMultiChildLayoutRender {  
  @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的寬/高為match_content,而其children的寬/高均為match_parent。這種情況在Android下會對同一個child進行"兩次measure",那麼在Flutter下,我們該如何實作呢?

Flutter如何解決"兩次Measure"的問題

我們先來看一個例子:

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?Flutter動态模闆渲染架構更新

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

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

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

  1. 先在performResize中計算自身size,再通過child.layout确定children sizes
  2. 先通過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);
    return 0.0;
  }           

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

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

成果與展望

效果展示

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

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?Flutter動态模闆渲染架構更新

性能對比

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

做一個高一緻性、高性能的Flutter動态渲染,真的很難麼?Flutter動态模闆渲染架構更新

展望

​ 在渲染架構更新之後,我們徹底解決了之前遇到的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

繼續閱讀