如何在Flutter上实现高性能的动态模板渲染
yuyutoo 2024-10-19 11:09 8 浏览 0 评论
背景
最近小组在尝试使用一套阿里dinamicX的DSL,通过动态模板下发,实现Flutter端的动态化模板渲染;本来以为只是DSL到Widget的简单映射和数据绑定,但实际跑起来的效果出乎意料的差,列表卡顿严重,帧率丢失严重。这就让我们不得不深入Flutter的Framework层,去了解Widget的创建、布局以及渲染的过程。
为什么Native可行的方案在Flutter效果这么差
在iOS和Android开发中,DSL到Native的方案其实并不陌生;Android中,我们就是通过编写XML文件来描述页面布局。Native的这种映射的方案,为什么在Flutter上,效果变得如此糟糕呢?
先通过一个简单的示例来看一下dinamicX DSL的定义:
可以看到DSL的设计与Android中的XML很相似,在我们的DSL中,每个节点的width和height属性,可以赋值两种特殊意义的值:match_parent和match_content。
match_parent:当前节点大小,尽量撑开到父节点大小;
match_content:当前节点大小,尽量缩小到容纳子节点大小;
在Flutter中,并没有match_parent和match_content的概念。最初我们的想法很简单,在Widget的build方法中,如果属性是match_parent,就不断向上遍历,直到找到一个父节点有确定的宽高值为止;如果是match_content,遍历所有的子节点,获取子节点大小;一旦子节点存在match_content属性,会递归调用下去。
表面上看,做好每个节点的宽高计算的缓存,虽然达不到一次性线性布局,这样的开销也并不是很大。但我们忽略掉了一个很重要的问题:Widget是immutable的,只是包含了视图的配置信息,是非常轻量级的。在Flutter中,Widget会被不断的创建销毁,这会导致布局计算非常的频繁。
要解决这些问题,单单处理Widget是不够的,需要Element以及RenderObject上做更多的处理,这也就是我们为什么要考虑自定义Widget的原因。
接下来通过源码来了解Flutter中Widget的build、layout以及paint相关的逻辑。
认识三棵树
我们通过一个简单的Widget——Opacity来了解一下Widget、Element、RenderObject。
Widget
在Flutter中,万物皆是Widget,Widget是immutable的,只是包含了视图的配置信息的描述,是非常轻量级的,创建和销毁的开销比较小。
Opacity继承自RenderObjectWidget,其定义了两个比较关键的函数:
RenderObjectElement createElement(); RenderObject createRenderObject(BuildContext context);
这正是我们要找的Element和RenderObject!这里只是定义了创建的逻辑,具体调用的时机我们继续往下看。
Element
在SingleChildRenderObjectWidget可以看到创建了SingleChildRenderObjectElement对象。
Element是Widget的抽象,在Widget初始化的时候,调用Widget.createElement创建,Element持有Widget和RenderObject;BuildOwner通过遍历Element Tree,根据是否标记为dirty,构建RenderObject Tree;在整个视图构建过程中,起到了串联Widget和RenderObject的作用。
RenderObject
Opacity的createRenderObject函数创建了RenderOpacity对象,RenderObject真正提供给Engine层渲染所需要的数据,RenderOpacity的Paint方法中找到了真正绘制的地方:
void paint(PaintingContext context, Offset offset) { if (child != null) { ... context.pushOpacity(offset, _alpha, super.paint); } }
通过RenderObject,我们可以处理layout、painting以及hit testing。这是我们在自定义Widget处理最多的事情。RenderObject只是定义了布局的接口,并未实现布局模型,RenderBox为我们提供了2D笛卡尔坐标系下的Box模型协议定义,大部分情况下,都可以继承于RenderBox,通过重载实现一个新的layout实现,paint实现,以及点击事件处理等;
Flutter在Layout过程中的优化
Flutter采用一次布局的方式,O(N)的线性时间来做布局和绘制。
如上图所示,在一次遍历中,父节点调用每个子节点的布局方法,将约束向下传递,子节点根据约束,计算自己的布局,并将结果传回给父节点;
RelayoutBoundary优化
当一个节点满足如下条件之一,该节点会被标记为RelayoutBoundary,子节点的大小变化不会影响到父节点的布局:
- parentUsesSize = false:父节点的布局不依赖当前节点的大小
- sizedByParent = true:当前节点大小由父节点决定
- constraints.isTight:大小为确定的值,即宽高的最大值等于最小值
- parent is not RenderObject:如果父节点不是RenderObject,子节点layout变化不需要通知父节点更新
RelayoutBoundary的标记,子节点大小变化,不会通知父节点重新layout,重新paint,从而提高效率。
Element更新优化
为什么Widget频繁创建销毁不会影响渲染性能呢?
Element定义了updateChild的方法,最早在Element被创建,Framework调用mount的时候,以及RenderObject被标记为needsLayout执行RenderObject.performLayout等场景,会调用Element的updateChild方法;
Element updateChild(Element child, Widget newWidget, dynamic newSlot) { ... if (child != null) { ... if (Widget.canUpdate(child.widget, newWidget)) { ... child.update(newWidget); ... } } }
对于child和newWidget都不为空的情况,通过Widget.canUpdate来判断当前child Element是否可以更新而非重现创建的方式update。
static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; }
我们可以看到Widget.canUpdate的定义,通过runtimeType和key比较来判断;如果可以更新,更新Element子节点;否则deactivate子节点的Element,根据newWidget创建新的Element。
我们如何自定义Widget
第一个版本的设计
在第一个版本的设计中,我们考虑的比较简单,所有的组件都继承与Object,实现一个build方法,根据DSL转换的nodeData设置Widget的属性:
我们用一个简单的例子来看,我们以最坏的情况来考虑,第一个节点都是match_content属性,每一次Widget创建,我们需要的布局计算:
这样每一次Widget更新,顶部节点的大小计算,都要深度遍历整个树。如果Widget其中一个节点更新,又会怎样呢?
答案是全部重新计算一遍,因为Widget是immutable的,在不断重新创建销毁。在最坏情况,会达到O(N2),可想而知一个长列表会表现如何。
第二个版本的设计
第二个版本,我们选择自定义Widget、Element以及RenderObject;下面是我们一部分组件的类图。
其中虚线框内是我们自定义的Widget组件。从上面的图可以看出,我们自定义的Widget大致分为三种类型:
- 只能作为叶子节点的Widget:如Image、Text,继承自CustomSingleChildLayout;
- 可以设置多个子节点的Widget:如FrameLayout、LinearLayout,继承自CustomMultiChildLayout;
- 可滚动的列表类型的Widget:如ListLayout、PageLayout,继承自CustomScrollView;
在自定义的RenderObject中,对于点击事件以及paint方法,并未做特殊处理,都交由组合的Widget处理。
@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); }
如何处理match_content
当前节点的宽高设置为match_content,需要先计算子节点的大小,然后再计算当前节点的大小。
在实现自定义的RenderObject中,我们需要重写performLayout方法;performLayout方法中,主要的需要做的事:
- 调用所有子节点的layout方法;
- 如果sizedByParent为false,需要设置自己size的大小;
下面以一个child的情况为例(如:Padding),在RenderObject中,对于match_content属性的节点,在调用child layout方法时,将parentUsesSize设置为true;然后size根据child.size设置。
这样做的一个好处,当child的大小变化的时候,自动会将parent设置为needLayout,parent由于被标记为needLayout,会在当前Frame的Pipline中重新layout、paint。当然这样也会带来性能的损耗,这一点需要特别注意。
@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; }
多child的情况,可以参考RenderSliverList的内部实现。
如何处理match_parent
如果当前节点的宽高设置为match_parent,尽量扩充到父节点大小;这种情况下,在Constraints向下传递的时候,根据父节点的约束,无需子节点计算,就已经知道自己的大小;在RenderObject中为我们提供了一个属性sizedByParent,默认为false,如果属性设置为match_parent,我们会给当前RenderObject的sizedByParent设置为true;这样在Constraints向下传递的时,子节点已经知道自己的大小,无需layout计算,在性能上有所提升。
在RenderObject中,当sizedByParent设置为true,需要重载performResize方法:
@override void performResize() { size = constraints.biggest; }
这里需要注意的一点,这种情况下,在重载performLayout方法时,不要再设置size的大小。
如果绑定的数据发生变化,改变sizedByParent之后,确保调用markNeedsLayoutForSizedByParentChange方法,将当前节点以及他的父节点设置为needsLayout,重新计算布局,重新绘制。
前后方案对比
在第二个版本的设计中,一个Widget渲染,需要怎样一个计算过程呢呢?
相同的场景,在RenderObject中,通过performLayout方法,将Constraints向下传递,child的size计算,并且向上传递,最终一次遍历就可以完成整个树的layout计算。
如果是上面更新的场景又会如何呢?
根据我们上面讲的Element更新过程以及RenderObject的RelayoutBoundary优化,可以看出,有新的Widget属性变化,Element Tree无需重建,更新当前Element节点,RenderObject在RelayoutBoundary的优化下,只需要更少的layout计算。
经过新方案的优化,长列表滑动的平均帧率从28提升到了50左右。
目前存在的问题
目前我们在自定义Widget的实现中,其实还是存在问题的。如果仔细看上面performLayout的实现,我们在调用每个child的layout方法的时候,parentUsesSize都设置为true;实际上只有当前节点属性为match_content的时候,这才是有必要的。目前我们的处理过于简单,导致RelayoutBoundary的优化没有真正享受到。所以目前实际的情况是,每次Widget的更新,都会导致2N次的Layout计算。这也是帧率达不到Flutter页面的其中一个原因,这也是我们接下来要解决的问题。
更多优化方向
经过一系列的优化之后,页面的卡顿情况终于有所改善,卡顿不再特别明显,但整体帧率仍然达不到Flutter页面的效果。仍然需要对Flutter有更深入的理解,挖掘出过多性能优化的点,进一步做一些更精细化的优化。
ListView和ScrollView,在Flutter中都有做性能优化处理。但是对于FrameLayout、LieanrLayout这样有多个child的layout,无法享受ListView提供的性能优化。我们是否可以借鉴ListView的ViewPort的概念,对于超出屏幕的部分,不去做layout、paint渲染。当然这需要考虑Engine层layer缓存等情况,需要后续进一步的研究。
另外在parentData存储,增加数据缓存以减少数据绑定次数方面,以及List嵌套List等复杂情况的优化处理,也都需要不断探索。
展望
目前我们实现了DSL到Widget的映射,这让Flutter动态模板渲染成为了可能。DSL是一种抽象,XML只是其中的一种选择,未来在不断完善性能的同时,还会提升整个方案的抽象,能够支持通用的DSL转换,沉淀一套通用解决方案,更好的通过技术赋能业务。
DSL到Widget的转换只是其中一环,从模板的编辑、本地验证、CDN下发、灰度测试、线上监控等整个闭环,仍然有很多需要不断打磨和完善的地方。
作者:闲鱼技术-光酒
相关推荐
- 高一高二第一次月考认真作答(高二第一次月考的重要性)
-
正在进行高一、高二第一次月考,同学们正在认真完成化学试卷,研究考纲,探究考点,夯实基础,迎战高考!
- 山清水秀,盛世今朝(山清水秀出处)
-
万千星河,神州妖娆!山清水秀,盛世今朝!龙腾虎跃,锦绣前程!千里婵娟,祝福永远!
- 我校二模成绩已新鲜出炉(二模考试成绩)
-
充电加油备战高考,积极努力再拼一搏...
- Argon Design向瑞萨电子有限公司提供Argon Streams VP9许可证
-
英国剑桥--(美国商业资讯)--领先的先进视频验证解决方案提供商ArgonDesignLtd已与日本半导体公司瑞萨电子有限公司(RenesasElectronicsCorporation)签署...
- 高考倒计时75天(高考倒计时75天励志语)
-
今天是2022年3月24日星期四,距离2022年高考还有75天时间对于十八岁的高三学子来说,有些事情的确会影响你们的一生,但是没有一件事能决定你们的一生!努力的意义,就是:以后的日子里,放眼望去,全...
- 期中考试正在进行(期中考试在即)
-
转眼即瞬,期中考试已到,紧张忙碌的两个月学习,检验的时刻到了。让我们拿出信心和勇气,来挑战自我。面对考验,我们该做的就是沉着,冷静。让知识来一次次洗礼我们的灵魂,让失败和成功迎接一次次的成长。你们可以...
- 不要浪费了你NAS上的HDMI接口!详解华硕NAS上HDMI接口的妙用
-
不要浪费了你NAS上的HDMI接口!详解华硕NAS上HDMI接口的妙用之前我在本站分享我使用的华硕(ASUS)AS6704T...
- Java通过Kafka Streams库来实现数据流处理
-
#暑期创作大赛#...
- From abandoned mines to limpid streams waters: how banks profit from EOD
-
ByZENGYanglinInthecurrentpursuitofthe“dualcarbon”target(carbonpeakingandcarbonneutra...
- SPSS与Streams的集成实现实时预测
-
SPSSModeler是一个数据挖掘工作台,提供了一个可了解数据并生成预测模型的最先进的环境。Streams提供了一个可伸缩的高性能环境,对不断变化的数据进行实时分析,这些数据中包括传统结构的数据...
- Kafka Streams, 我还会再使用它吗?
-
DeeptiMittal4分钟阅读...
- 大数据Hadoop之——Kafka Streams原理介绍与简单应用示例
-
一、KafkaStreams概述官网文档:https://kafka.apache.org/32/documentation/streams/...
- Android上的TCP今天开始向用户推出,并将在下个月向所有用户提供
-
据extends网3月15日报道,Firefox今天宣布,其保护用户免受跟踪器攻击的全面cookie保护(TCP)功能现已在Android上可用。该功能默认启动模式,这样,跟踪器将无法收集有关用户的浏...
- Linux curl命令(linux curl命令安装)
-
Linuxcurl命令是一个利用URL规则在命令行下工作的文件传输工具。它支持文件的上传和下载,所以是综合传输工具,但按传统,习惯称curl为下载工具。作为一款强力工具,curl支持包括HTTP、H...
- go语言http服务入门详解(go语言http服务器)
-
当你在浏览器中输入URL时,实际上是在发送一个对Web页面的请求。该请求被发送到服务器。服务器的工作是获取适当的页面并将其作为响应发送回浏览器。在Web的早期,服务器通常读取服务器硬盘上HTML文件的...
你 发表评论:
欢迎- 一周热门
-
-
前端面试:iframe 的优缺点? iframe有那些缺点
-
带斜线的表头制作好了,如何填充内容?这几种方法你更喜欢哪个?
-
漫学笔记之PHP.ini常用的配置信息
-
推荐7个模板代码和其他游戏源码下载的网址
-
其实模版网站在开发工作中很重要,推荐几个参考站给大家
-
[干货] JAVA - JVM - 2 内存两分 [干货]+java+-+jvm+-+2+内存两分吗
-
正在学习使用python搭建自动化测试框架?这个系统包你可能会用到
-
织梦(Dedecms)建站教程 织梦建站详细步骤
-
【开源分享】2024PHP在线客服系统源码(搭建教程+终身使用)
-
2024PHP在线客服系统源码+完全开源 带详细搭建教程
-
- 最近发表
-
- 高一高二第一次月考认真作答(高二第一次月考的重要性)
- 山清水秀,盛世今朝(山清水秀出处)
- 我校二模成绩已新鲜出炉(二模考试成绩)
- Argon Design向瑞萨电子有限公司提供Argon Streams VP9许可证
- 高考倒计时75天(高考倒计时75天励志语)
- 期中考试正在进行(期中考试在即)
- 不要浪费了你NAS上的HDMI接口!详解华硕NAS上HDMI接口的妙用
- Java通过Kafka Streams库来实现数据流处理
- From abandoned mines to limpid streams waters: how banks profit from EOD
- SPSS与Streams的集成实现实时预测
- 标签列表
-
- mybatis plus (70)
- scheduledtask (71)
- css滚动条 (60)
- java学生成绩管理系统 (59)
- 结构体数组 (69)
- databasemetadata (64)
- javastatic (68)
- jsp实用教程 (53)
- fontawesome (57)
- widget开发 (57)
- vb net教程 (62)
- hibernate 教程 (63)
- case语句 (57)
- svn连接 (74)
- directoryindex (69)
- session timeout (58)
- textbox换行 (67)
- extension_dir (64)
- linearlayout (58)
- vba高级教程 (75)
- iframe用法 (58)
- sqlparameter (59)
- trim函数 (59)
- flex布局 (63)
- contextloaderlistener (56)