Flutter 动态模板渲染架构升级
最近小组在尝试使用集团 DinamicX 的 DSL,通过下发 DSL 模板,实现 Flutter 端的动态化模板渲染。我们解决了性能方面的问题后,又面临了一个新的挑战——渲染一致性。我们该如何在不降低渲染性能的前提下,大幅度提升 Flutter 与 Native 之间的渲染一致性呢?
挑战与思路
在初版渲染架构设计当中,我们以 Widget 为中心,采用了组合的方案来完成 DSL 到 Widget 的转化。这方面的工作在早期还算比较顺利,然而随着模板复杂度的增加,逐渐出现了一些 Bad Case。
我们分析了这些 Bad Case 后发现,在初版渲染架构下,无法彻底解决这些 Bad Case,原因主要为以下两点:
- 我们使用了 Stack 来代表 FrameLayout,Column/Row 来代表 LinearLayout,它们看似功能相似,实则内部实现差异较大,使用过程中引起了很多难以解决的 Bad Case。
- 初版我们尝试通过自定义 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 上的一颗子树,这棵子树主要由三部分组成。
- Decoration 层:Decoration 层用于支持背景色、边框、圆角、触摸事件等,这些我们可以通过组合方式实现。
- Render 层:Render 层用于表达 Node 在转化后的布局规则与尺寸大小。
- 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 方法主要做了两件事:
- 确定当前 RenderObject 对应的 relayoutBoundary
- 调用 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 部分
- layout 所有的 children,如果 FrameLayoutRender 不是 sizedByParent,需要同时计算所有 children 的最大宽度与最大高度,用于计算自身 size。
- 计算自身 size,其中计算方案 defaultComputeSize 详见上一小节
- 将 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 过程为以下两种:
- 先在 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);
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
本文作者:闲鱼技术
阅读原文
本文为云栖社区原创内容,未经允许不得转载。