乐趣区

做一个高一致性高性能的Flutter动态渲染真的很难么

Flutter 动态模板渲染架构升级

​ 最近小组在尝试使用集团 DinamicX 的 DSL,通过下发 DSL 模板,实现 Flutter 端的动态化模板渲染。我们解决了性能方面的问题后,又面临了一个新的挑战——渲染一致性。我们该如何在不降低渲染性能的前提下,大幅度提升 Flutter 与 Native 之间的渲染一致性呢?

挑战与思路

​ 在初版渲染架构设计当中,我们以 Widget 为中心,采用了组合的方案来完成 DSL 到 Widget 的转化。这方面的工作在早期还算比较顺利,然而随着模板复杂度的增加,逐渐出现了一些 Bad Case。

​ 我们分析了这些 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 的情况):

​ 我们可以基于上面这个表格,计算出每个 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 来表达。

​ Render 层为我们新版渲染架构中的核心层,用于表达 Node 转化后的布局规则与尺寸大小,对于理解 DSL 布局理念起到了关键性作用,其类图如下:

​ 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” 的问题

我们先来看一个例子:

​ 上图的 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 动态模板方案带来了更多的可能性。

性能对比

​ 我们对新老版本的渲染性能做了测试对比,在新版渲染架构下,我们通过页面渲染耗时对比以及 FPS 对比可以发现,动态模板的渲染性能得到了进一步的提升。

展望

​ 在渲染架构升级之后,我们彻底解决了之前遇到的 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

双 11 福利来了!先来康康 #怎么买云服务器最便宜# [并不简单] 参团购买指定配置云服务器仅 86 元 / 年,开团拉新享三重礼:1111 红包 + 瓜分百万现金 +31% 返现,爆款必买清单,还有 iPhone 11 Pro、卫衣、T 恤等你来抽,马上来试试手气!https://www.aliyun.com/1111/2019/home?utm_content=g_1000083110


本文作者:闲鱼技术

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

退出移动版