直播预约通道开启!2021腾讯数字生态大会邀您共探产业发展新机遇! 了解详情
写点什么

如何在 Flutter 上实现高性能的动态模板渲染

2019 年 10 月 01 日

如何在Flutter上实现高性能的动态模板渲染

背景

最近小组在尝试使用集团 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_parentmatch_content


在 Flutter 中,并没有 match_parentmatch_content的概念。最初我们的想法很简单,在 Widget 的 build 方法中,会递归计算。


表面上看,做好每个节点的宽高计算的缓存,虽然达不到一次性线性布局,这样的开销也并不是很大。但我们忽略掉了一个很重要的问题:Widget 是 immutable 的,在 Flutter 中,Widget 会被不断的创建销毁,这会导致布局计算非常的频繁。


要解决这些问题,单单处理 Widget 是不够的,需要 Element 以及 RenderObject 上做更多的处理,这也就是我们为什么要考虑自定义 Widget 的原因。


接下来通过源码来了解 Flutter 中 Widget 的 build、layout 以及 paint 相关的逻辑。


认识三棵树

我们通过一个简单的 Widget—— Opacity来了解一下 WidgetElementRenderObject


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 的定义,通过 runtimeTypekey比较来判断;如果可以更新,更新 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 页面的其中一个原因,这也是我们接下来要解决的问题。


展望

目前我们实现了 DSL 到 Widget 的映射,这让 Flutter 动态模板渲染成为了可能。DSL 是一种抽象,XML 只是其中的一种选择,未来在不断完善性能的同时,还会提升整个方案的抽象,能够支持通用的 DSL 转换,沉淀一套通用解决方案,更好的通过技术赋能业务。


DSL 到 Widget 的转换只是其中一环,从模板的编辑、本地验证、CDN 下发、灰度测试、线上监控等整个闭环,仍然有很多需要不断打磨和完善的地方。


参考文章


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


原文链接


https://mp.weixin.qq.com/s/fX6DtXYtKw0hFqf7t---eA


2019 年 10 月 01 日 08:001525

评论

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

架构师第八周

Tulane

领域驱动设计 学习笔记

半亩房顶

DDD

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第4章CSS文本样式

Geek_8dbdc1

CSS

Week08作业

熊威

网络通讯

陈皮

躬履艰难,其节乃见:华为陈黎芳眼中的全球责任

脑极体

架构师训练营——第8周作业

jiangnanage

作业-第八周

superman

找出两个链表交点(golang版)

2流程序员

第八周课后作业

晨光

架构师训练营第八周总结

Geek_2dfa9a

Week08总结

熊威

哪些资源容易造成性能瓶颈

彭阿三

一个文学青年的至暗时刻

半亩房顶

反思 就业

第八周课后总结

晨光

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第3章初识CSS

Geek_8dbdc1

CSS

第八周课程总结

考尔菲德

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第5章CSS盒子模型

Geek_8dbdc1

CSS

初识 - DDD-CQRS

半亩房顶

DDD CQRS

第八周心得

方堃

架构师课作业 - 第八周

Tulane

第八周作业

Geek_a327d3

从 1.9 到 1.11,聊聊 PyFlink 的核心功能演进(附 Demo 代码)

Apache Flink

flink

第八周作业

李白

第八周总结

李白

作业:链表交叉点

考尔菲德

EasyDL全新升级,文心(ERNIE)3项能力助力快速定制企业级NLP模型

百度大脑

人工智能 nlp 百度大脑

架构师培训 -08 数据结构算法,网络通信协议,非阻塞网络I/O,数据库原理

刘敏

信创舆情一线--工信部开展APP侵害用户权益专项整治行动

统小信uos

架构师训练营第八周作业

Geek_2dfa9a

架构师训练营——第8周学习总结

jiangnanage

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

如何在Flutter上实现高性能的动态模板渲染-InfoQ