×
Community Blog Flutter Analysis and Practice: Practices of High-Performance Dynamic Template Rendering

Flutter Analysis and Practice: Practices of High-Performance Dynamic Template Rendering

This article describes how Xianyu used DinamicX's DSL to deliver dynamic templates and implement dynamic template rendering on the Flutter side.

With the booming development of mobile smart devices, a mobile multi-terminal development framework has become a general trend. Download the Flutter Analysis and Practice: Evolution and Innovation of Xianyu Technologies eBook for step-by-step analyses based on real-life problems, as well as clear explanations of the important concepts of Flutter.

Xianyu tried to use DinamicX's domain specific language (DSL) to deliver dynamic templates and implement dynamic template rendering on the Flutter side. We thought it was just simple mapping and data binding from DSL to widgets, but the actual effect of the operation was very poor, with serious list lagging and frame losses. Therefore, we have to analyze the widget creation, layout, and rendering at the Flutter framework layer.

3.4.1 Why Is the Native Solution Not Applicable to Flutter?

DSL-to-native solutions are frequently used during iOS and Android app development. On Android, we describe the page layout by writing XML files. Why is the native mapping scheme not feasible on Flutter?

Let's take a look at a simple example for DSL definition.

1

As shown in the preceding figure, the design of DSL is similar to an XML file on Android. The Width and Height attributes of each node in the DSL file can be set to match_parent and match_content, respectively.

  • match_parent: the size of the current node. Make it as large as the parent node.
  • match_content: the size of the current node. Reduce it to the size of a child node.

In Flutter, match_parent and match_content are unavailable. Initially, our idea was very simple. In the build method of widgets, if the attribute is match_parent, traverse upward until we find a parent node with certain width and height values. If the attribute is match_content, traverse all child nodes to obtain their sizes. If a child node has the match_content attribute, we recursively call the child node.

Caching the width and height calculation on each node cannot realize a one-time linear layout, but the overhead is not very large. However, the widget is immutable and lightweight and only contains the view configuration information. In Flutter, widgets are frequently created and destroyed, which leads to frequent layout calculations.

To solve these problems, we need to deal with widgets and do more processing on the Element and RenderObject. This is why custom widgets are required.

Next, let's learn about the build-, layout-, and paint-related logic of widgets in Flutter according to the source code.

3.4.2 Three Trees

The following sections describe Widget, Element, and RenderObject through a simple widget, Opacity.

3.4.2.1 Widget

In Flutter, everything is a widget. A widget is immutable and lightweight and only contains the view configuration information. The overhead of creating and deleting widgets is small.

Opacity inherits from RenderObjectWidget and defines two critical functions:

RenderObjectElement createElement();

RenderObject createRenderObject(BuildContext context);

Element and RenderObject – Here we define only the creation logic. The specific call time will be introduced later.

3.4.2.2 Element

In SingleChildRenderObjectWidget, you can see that a SingleChildRenderObjectElement object has been created.

Element is an abstraction of a widget. Widget.createElement is called to create a widget. Element holds Widget and RenderObject. BuildOwner traverses the Element tree and builds a RenderObject tree based on whether an Element is marked as dirty. Element concatenates Widget and RenderObject during the entire view build process.

3.4.2.3 RenderObject

The createRenderObject function of Opacity creates the RenderOpacity object and RenderObject provides the data required for rendering for the engine layer. The Paint method of RenderOpacity finds the target for rendering.

void paint(PaintingContext context, Offset offset)
    {   
        if (child != null) 
        {   
            ...   context.pushOpacity(offset, _alpha, super.paint);   
        }  
     }    

By using RenderObject, we can handle layout, rendering, and hit testing. This is what we handle most in a custom widget. RenderObject only defines the layout interface and does not implement the layout model. RenderBox provides the definition of the BoxModel protocol in the 2D Cartesian coordinate system. In most cases, RenderObject can inherit from RenderBox and implement a new layout, rendering, and tap event processing through reloading.

3.4.3 Flutter Optimization During Layout

Flutter uses a one-time layout and the O(N) linear time for layout and rendering, as shown in Figure 3-14. During a traversal, a parent node calls the layout method of each child node and passes the constraints down. A child node calculates its own layout according to the constraints and passes the result back to the parent node.

2
Figure 3-14

3.4.3.1 RelayoutBoundary Optimization

If a node meets any of the following conditions, the node is marked as RelayoutBoundary and the changes in the size of a child node do not affect the layout of its parent node:

  • parentUsesSize = false: The layout of the parent node does not depend on the size of the current node.
  • sizedByParent = true: The size of the current node is determined by its parent node.
  • constraints.isTight: The value is fixed. The maximum width and height are the same as the minimum ones.
  • parent is not RenderObject: If the parent node is not RenderObject, the parent node does not need to be informed of the changes in the child node layout.

When the RelayoutBoundary mark and the child node size changes, the parent node is not instructed to re-layout or render, as shown in Figure 3-15. This improves efficiency.

3
Figure 3-15

3.4.3.2 Element Update and Optimization

Why does frequent creation and destruction of widgets not affect rendering performance? Element defines the updateChild method. This method is called when Element is created, Framework calls mount, and RenderObject is marked as needsLayout to execute RenderObject.performLayout.

Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
    ...
    if (child != null) {
        ...
        if (Widget.canUpdate(child.widget, newWidget)) {
            ...
            child.update(newWidget);
            ...
        }    
    }
}

If both child and newWidget have been set, you can use Widget.canUpdate to determine whether the current child element can be updated or reused.

static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
    && oldWidget.key == newWidget.key;
  }    

Widget.canUpdate determines whether the current child element can be updated based on runtimeType and key. If yes, the Element child node is updated. If no, Element of the child node is deactivated and a new Element is created based on newWidget.

3.4.4 How Can Widgets Be Customized?

3.4.4.1 Design of the First Version

In the first version, all components inherit from Object and a build method was implemented to set the widget properties based on the nodeData converted by DSL, as shown in Figure 3-16.

4
Figure 3-16

For example, the first node has the match_content attribute. Each time a widget is created, the layout needs to be calculated, as shown in Figure 3-17.

5
Figure 3-17

In this way, every time a widget is updated, the entire tree is traversed to calculate the size of the top node. What if a widget node is updated?

6
Figure 3-18

The calculation needs to be performed all over again because widgets are immutable and are frequently recreated and destroyed. In the worst case, the number of calculations will reach O(N2). You can imagine what it would be for a long list.

3.4.4.2 Design of the Second Version

In the second version, Widget, Element, and RenderObject are customized. Figure 3-19 shows a class diagram of some components.

7
Figure 3-19

In the figure, the components within the dashed line box are the custom widgets, which are roughly divided into three types:

  • Widgets that can only be used as leaf nodes, such as Image and Text, which inherit from CustomSingleChildLayout.
  • Widgets that can be configured with multiple child nodes, such as FrameLayout and LinearLayout, which inherit from CustomMultiChildLayout.
  • Widgets of scroll list type, such as ListLayout and PageLayout, which inherit from CustomScrollView.

In the custom RenderObject, the tap events and the rendering method are directly processed by widget combinations.

@override
  bool hitTestChildren(HitTestResult result, {Offset position}) {   return child?.hitTest(result, position: position) ?? false;   }
  @override   void paint(PaintingContext context, Offset offset) {   if (child != null) context.paintChild(child, offset);   }

1) How can we handle match_content?

The width and height values of the current node are set to match_content. You need to calculate the size of the child nodes before calculating the size of the current node.

To implement the custom RenderObject, we need to rewrite the performLayout method. The performLayout method performs the following operations:

  • Calls the Layout method of all child nodes.
  • If the sizedByParent parameter is set to false, you must set the size of the node.

Take a child node as an example (such as Padding.) In RenderObject, for a node with the match_content attribute, when the child layout method is called, set parentUsesSize to true and set the size according to child.size.

In this way, when the size of the child node changes, the parent node is automatically marked as needsLayout, and the layout and rendering of the current frame will be reset in the pipeline. This also causes performance loss.

@override
  void performLayout() {   assert(callback != null);   invokeLayoutCallback(callback);   if (child != null) {   child.layout(constraints, parentUsesSize: true);   size = constraints.constrain(child.size);   } else {   size = constraints.biggest;  }  

In the case of multiple child nodes, you can refer to the internal implementation of RenderSliverList.

2) How can we handle match_parent?

If the width and height of the current node are set to match_parent, make it as large as the parent node. In this case, when constraints are passed down, the node size is known without calculation. RenderObject provides the sizedByParent attribute, which defaults to false. If the attribute is set to match_parent, sizedByParent of the current RenderObject is set to true. In this way, when constraints are passed down, the child node size is known without layout calculation. This improves the performance.

In RenderObject, when sizedByParent is set to true, the performResize method must be reloaded.

@override
  void performResize() {   size = constraints.biggest;   }   

In this case, do not set the size when reloading the performLayout method.

After sizedByParent is changed, be sure to call the markNeedsLayoutForSizedByParentChange method to set the current node and its parent node to needsLayout and recalculate and rerender the layout.

3.4.4.3 Scheme Comparison

Figure 3-20 shows the calculation process with one widget rendered in the second version.

8
Figure 3-20

Similarly, in RenderObject, the performLayout method is used to pass constraints down to calculate the child node size and pass it up. The layout calculation of the entire tree can be completed through one traversal.

As shown in Figure 3-21, what happens if it's in the update scenario?

9
Figure 3-21

According to the preceding Element update process and the RelayoutBoundary optimization of RenderObject, when new widget attributes are changed, the current Element node can be updated without the need to rebuild the Element tree. After the optimization of RelayoutBoundary, fewer layout calculations are required for RenderObject.

After the optimization, the average frame rate of long list sliding is increased from 28 to about 50.

3.4.4.4 Known Issues

Issues still exist in the implementation of custom widgets. According to the preceding performLayout implementation, parentUsesSize is always set to true when the layout method of each child node is called. However, parentUsesSize needs to be set to true only when the current node attribute is match_content. The current processing is too simple to take advantage of the RelayoutBoundary optimization. Therefore, each widget update leads to layout calculations of 2N times. This is one of the reasons why the frame rate is lower than a Flutter page.

3.4.5 More Optimization Directions

Currently, we have implemented the mapping of DSL to widgets, which makes Flutter dynamic template rendering possible. DSL is an abstraction, while XML is only one of the options. We will not only continuously improve the performance but also improve the abstraction of the entire solution to support general DSL conversion. We will provide a set of universal solutions to better empower businesses through technologies.

The conversion from DSL to widgets is only one part of the process. In the closed loop from template edition, local verification, Alibaba Cloud Content Delivery Network (CDN) delivery, phased testing, to online monitoring, further optimizations are still needed.

References

  1. https://flutter.dev/docs/resources/inside-flutter.
  2. https://www.youtube.com/watch?v=UUfXWzp0-DU.
  3. https://www.youtube.com/watch?v=dkyY9WCGMi0.
  4. https://github.com/flutter/flutter/issues/14330.
  5. https://www.dartlang.org/.
  6. https://mp.weixin.qq.com/s/4s6MaiuW4VoHr7f0SvuQ.
  7. https://github.com/flutter/engine.
0 0 0
Share on

XianYu Tech

56 posts | 4 followers

You may also like

Comments