阿里云「飞天发布时刻」2024来啦!新产品、新特性、新能力、新方案,等你来探~ 了解详情
写点什么

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

  • 2019-11-18
  • 本文字数:6397 字

    阅读完需:约 21 分钟

做一个高一致性、高性能的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 上的一颗子树,这棵子树主要由三部分组成。


  • 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 方法主要做了两件事:


  1. 确定当前 RenderObject 对应的 relayoutBoundary

  2. 调用 performResize 或 performLayout 去确定自己的 size


为了方便读者阅读将 layout 方法做了简化,代码如下:


abstractclassRenderObject{Constraintsget constraints => _constraints;Constraints _constraints;
boolget 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。


abstractclassDXRenderBoxextendsRenderBox{
DXRenderBox({@requiredthis.nodeData});DXWidgetNode nodeData;
@overrideboolget sizedByParent {return nodeData.widthMeasureMode == DXMeasureMode.DX_EXACTLY && nodeData.heightMeasureMode == DXMeasureMode.DX_EXACTLY;}
......}
复制代码

如何实现 performResize

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


abstractclassDXRenderBoxextendsRenderBox{......
@overridevoid 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 具体有多大,就是通过它来确定的。


abstractclassDXSingleChildLayoutRenderextendsDXRenderBoxwithRenderObjectWithChildMixin<RenderBox> {
@overridevoid performLayout() {BoxConstraints childBoxConstraints = computeChildBoxConstraints();if(sizedByParent) { child.layout(childBoxConstraints);} else{ child.layout(childBoxConstraints, parentUsesSize: true); size = defaultComputeSize(child.size);}}
......}
复制代码


首先,我们先计算出 childBoxConstraints。接着判断其是否是 sizedByParent。如果是,那么其 size 已经在 performResize 阶段计算完成,此时只需要调用 child.layout 方法即可。否则,需要在调用 child.layout 时将 parentUsesSize 参数设置为 true,通过 child.size 来计算其 size。可是该如何根据 child.size 来计算 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));}
复制代码


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

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

布局空间如何实现 performLayout

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


classDXFrameLayoutRenderextendsDXMultiChildLayoutRender{@overridevoid 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 sizeif(!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 的宽/高为 matchcontent,而其 children 的宽/高均为 matchparent。这种情况在 Android 下会对同一个 child 进行"两次 measure",那么在 Flutter 下该如何实现呢?

Flutter 如何实现两次 measure 的问题?

我们先来看一个例子:



上图的 LinearLayout 是一个竖向线性布局,width 被设为了 matchcontent,它包含了两个 TextView,width 均为 matchparent,那么这个例子中,整个布局的流程应该是怎样的呢。


首先需要依次 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 为例子来说明该如何实现这些方法。


@overridedouble computeMaxIntrinsicWidth(double height) {if(nodeData.width != null) {return nodeData.width;}if(child != null) return child.getMaxIntrinsicWidth(height);return0.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


本文转载自公众号闲鱼技术(ID:XYtech_Alibaba)。


原文链接


https://mp.weixin.qq.com/s/R6IxJqawwbmlWvlwb3ZXww


2019-11-18 08:002146

评论

发布
暂无评论
发现更多内容

MySQL探秘(二):SQL语句执行过程详解

程序员历小冰

MySQL 28天写作 12月日更

12 月亚马逊云科技培训与认证课程,精彩不容错过!

亚马逊云科技 (Amazon Web Services)

架构师 培训 认证

【12月11日】真香现场,带你玩转 EKS!

亚马逊云科技 (Amazon Web Services)

人工智能 Meetup EKS

Springboot & RabbitMQ 延时队列的使用

编程江湖

大数据 消息中间件

Rust 元宇宙 11 —— Websocket

Miracle

rust websocket 元宇宙

模块五作业

危险游戏

架构实战营

Linux一学就会之Centos8用户管理

学神来啦

Linux centos 运维 linux云计算

周边生态|RoP 重磅发布 0.2.0 版本: 架构全新升级,消息准确性达 100%

Apache Pulsar

Java 开源 架构 云原生 Apache Pulsar

JDK 动态代理与 CGLIB 动态代理,它俩真的不一样

华为云开发者联盟

jdk 动态代理 spring aop JDK 动态代理 CGLIB 动态代理

带你了解Typescript的14个基础语法

华为云开发者联盟

typescript 数组 开发 js 语法

如何在Flutter应用程序中创建不同的渐变 【Flutter专题14】

坚果

flutter 28天写作 12月日更

☕【并发技术系列】「多线程并发编程」技术体系和并发模型的基础探究(夯实基础)

洛神灬殇

Java 并发编程 多线程 多进程 12月日更

六个数字化意识和习惯

明道云

Spark SQL之RDD转换DataFrame的方法

@零度

大数据 RDD DataFrame spark SQL

常用项目部署方案和区别

进击的梦清

DevOps 运维 项目 部署与维护

初识JVM的内存结构

Ayue、

技术专题合集

大咖联袂发布!《慧技术·惠金融——2022金融科技趋势研究报告》开放下载

恒生LIGHT云社区

金融科技 行业趋势 行业大会

前端开发面试题分享,看一下是不是你需要的

@零度

大前端 面试题

web技术分享| AudioContext 实现音频可视化

anyRTC开发者

Web 音视频 WebRTC 音频可视化 AudioContext

恒源云(GPUSHARE)_分享一个技巧!CV训练时容易忽视的数据标签问题

恒源云

深度学习 算法 CV

极光笔记|百亿级KV存储在极光的运维实践之路

极光JIGUANG

运维监控场景下,如何从OpenTSDB迁移到TDengine

TDengine

数据库 tdengine

Java开发之如何连接Redis

@零度

redis JAVA开发

Rust 元宇宙 从零开始构建

Miracle

rust 元宇宙

【云小课】CDN第5课 CDN入门之—我的网站可以用CDN加速吗?

华为云开发者联盟

网站 CDN 网络 CDN加速 华为云CDN

最受欢迎的5个React动画库

编程江湖

React

Go语言学习查缺补漏ing Day1

恒生LIGHT云社区

编程语言 Go 语言

Hutool中那些常用的工具类和方法

编程江湖

JAVA开发 java工具包

工具 | PG 集群复制管理工具 repmgr

RadonDB

数据库 postgresql RadonDB

dart系列之:还在为编码解码而烦恼吗?用dart试试

程序那些事

flutter dart flutter 面向切面 aop 程序那些事 12月日更

推开“微前端”的门

百度Geek说

微服务 大前端

做一个高一致性、高性能的Flutter动态渲染,真的很难么?_大前端_皓黯_InfoQ精选文章