15

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

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzU4MDUxOTI5NA%3D%3D&%3Bmid=2247484829&%3Bidx=1&%3Bsn=d0698f7400d7a7b6c7e8254cb4273949
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

思路

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

640?wx_fmt=jpeg

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

zueEN3Z.jpg!web

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

viYvmej.jpg!web

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

AnmMnim.jpg!web

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方法做了简化,代码如下:

可以说只要掌握了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。

如何实现 performResize

只有sizedByParent为true时,也就是widthMeasureMode和heightMeasureMode均为DX EXACTLY时,performResize方法才会被调用。而若widthMeasureMode和heightMeasureMode均为DX EXACTLY,则证明nodeData的宽高要么是具体值,要么是match parent,所以在performResize方法里只需要处理宽/高为具体值或match parent的情况即可。 宽/高有具体值取具体值,没有具体值则表示其为match_parent,取constraints的最大值。

非布局空间如何实现performLayout

DXRenderBox作为所有控件Render层的基类,无需实现performLayout。不同的DXRenderBox的子类对应的performLayout方法是不同的,这个方法也是Flutter理解DSL的关键。接下来以DXSingleChildLayoutRender为例子来说明performLayout的实现思路。

DXSingleChildLayoutRender的主要作用是确定非布局控件的大小。比如一个ImageView具体有多大,就是通过它来确定的。

首先,我们先计算出childBoxConstraints。 接着判断其是否是sizedByParent。 如果是,那么其size已经在performResize阶段计算完成,此时只需要调用child.layout方法即可。 否则,需要在调用child.layout时将parentUsesSize参数设置为true,通过child.size来计算其size。 可是该如何根据child.size来计算size呢?

  • 如果宽/高所对应的measureMode为DX EXACTLY,那么最终宽/高则有具体值取具体值,没有具体值则表示其为match parent,取constraints的最大值。

  • 如果宽/高所对应的measureMode为DX_ATMOST,那么最终宽/高取child的宽/高即可。

布局空间如何实现performLayout

布局控件在performLayout中除了需要确定自己的size以外,还需要设计好自己的布局规则。 以FrameLayout为例来说明一下布局控件的performLayout该如何实现。

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的问题?

我们先来看一个例子:

zuiAbq7.jpg!web

上图的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为例,来讲讲这些方法的用途。

getMaxIntrinsicWidth接收一个参数height,用于确定当height为这个值时maxIntrinsicWidth应该是多少。 这个方法最终会通过computeMaxIntrinsicWidth方法来计算maxIntrinsicWidth,计算结果会被保存。 如果需要重写,不应该重写getMaxIntrinsicWidth方法,而是应该重写computeMaxIntrinsicWidth方法。 需要注意的是这些方法并非轻量级方法,只有在真正需要的时候才可使用。

或许你不禁要问,这些方法计算出来的宽高准吗? 实际上每个RenderBox的子类都需要保证这些方法的正确性,比如用于展示文字的RenderParagraph就实现了这些compute方法,因此得以在RenderParagraph没被layout之前,获取其宽度。

我们设计的Render层中的类也得实现compute方法,这些方法实现起来并不复杂,还是以DXSingleChildLayoutRender为例子来说明该如何实现这些方法。

上述代码比较简单,不再赘述。

那么我们再简单看一下例子中的问题—— 先通过child.getMaxIntrinsicWidth来计算每个child需要的width。 接着将这些宽度的最大值确定LinearLayout的width,最后通过child.layout对每个孩子进行布局,传入的constraints的maxWidth和minWidth均为LinearLayout的width。

效果

新版渲染架构使得Flutter能理解并对齐DSL的布局理念,系统性解决了之前遇到的Bad Case,为Flutter动态模板方案带来了更多的可能性。

I7bIZrq.jpg!web

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

iIru6vF.jpg!web

后续展望

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

闲鱼团队是Flutter+Dart FaaS前后端一体化新技术的行业领军者,就是现在! 客户端/服务端java/架构/前端/质量工程师 面向社会招聘,base杭州阿里巴巴西溪园区,一起做有创想空间的社区产品、做深度顶级的开源项目,一起拓展技术边界成就极致!

*投喂简历给小闲鱼→ [email protected]

quQ3Mn7.jpg!web

ZriEn26.png!web

开源项目、峰会直击、关键洞察、深度解读

请认准 闲鱼技术


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK