Flutter中Widget的build、layout以及paint相关的逻辑分析

admin 2024-04-27 216 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 中,每个节点的宽度和高度属性可以分配两个特殊值:match_parent 和 match_content。

在Flutter中,没有match_parent和match_content的概念。 最初我们的想法非常简单。 在Widget的构建方法中,将执行递归计算。

从表面上看,每个节点的宽高计算的缓存虽然无法实现一次性的线性布局,但开销并不是很大。 但我们忽略了一个非常重要的问题:Widget 是不可变的。 在Flutter中,Widget会不断地创建和销毁,这会导致非常频繁的布局计算。

要解决这些问题,单靠Widget是不够的。 需要对 Element 和 RenderObject 进行更多处理。 这就是为什么我们应该考虑自定义Widget。

接下来通过源码来了解Flutter中Widget的构建、布局、绘制相关的逻辑。

认识三棵树

我们通过一个简单的Widget——Opacity来了解一下Widget、Element、RenderObject。

小工具

在 Flutter 中,一切都是 Widget。 Widget 是不可变的,仅包含视图的配置信息的描述。 它们非常轻量级,创建和销毁的开销相对较小。

Opacity继承自RenderObjectWidget,它定义了两个关键函数:

  1. RenderObjectElement createElement();


  2. RenderObject createRenderObject(BuildContext context);

这正是我们要寻找的 Element 和 RenderObject! 这里我们只定义了创建逻辑,我们继续看具体的调用时机。

元素

可以看到在SingleChildRenderObjectWidget中创建了SingleChildRenderObjectElement对象。

Element是Widget的抽象。 当Widget初始化时,会调用Widget.createElement来创建它。 Element持有Widget和RenderObject; BuildOwner遍历Element Tree,根据是否标记为dirty构建RenderObject Tree; 在整个视图构建过程中,它起到了串联Widget和RenderObject的作用。

渲染对象

Opacity 的 createRenderObject 函数创建一个 RenderOpacity 对象。 RenderObject实际上向Engine层提供了渲染所需的数据。 实际绘制的地方在RenderOpacity的Paint方法中找到:

  1. void paint(PaintingContext context, Offset offset) {

  2. if (child != null) {

  3. ...

  4. context.pushOpacity(offset, _alpha, super.paint);

  5. }

  6. }

通过RenderObject,我们可以处理布局、绘制和命中测试。 这是我们在自定义 Widget 中处理最多的事情。 RenderObject只定义了布局接口,并没有实现布局模型。 RenderBox为我们提供了2D笛卡尔坐标系下的Box模型协议定义。 大多数情况下,可以继承自RenderBox,通过重载实现新的布局。 实现、paint实现、点击事件处理等;

Flutter在Layout过程中的优化

Flutter采用一次性布局方式,需要O(N)线性时间进行布局和绘制。

简历模板师工程支持技术怎么写_技术支持工程师简历模板_技术支持工程师简历怎么写

如上图所示,在一次遍历中,父节点调用各个子节点的布局方法,并向下传递约束。 子节点根据约束计算自己的布局,并将结果传回父节点;

Relayout边界优化

当节点满足以下条件之一时,该节点将被标记为RelayoutBoundary,子节点的大小变化不会影响父节点的布局:

RelayoutBoundary的标记在子节点大小变化时不会通知父节点进行重新布局和重绘,从而提高效率。

技术支持工程师简历模板_简历模板师工程支持技术怎么写_技术支持工程师简历怎么写

元素更新优化

为什么频繁创建和销毁Widget不会影响渲染性能?

Element 定义 updateChild 方法。 创建Element时,Framework调用mount,RenderObject被标记为needsLayout并执行RenderObject.performLayout,Element的updateChild方法会被调用;

  1. Element updateChild(Element child, Widget newWidget, dynamic newSlot) {

  2. ...

  3. if (child != null) {

  4. ...

  5. if (Widget.canUpdate(child.widget, newWidget)) {

  6. ...

  7. child.update(newWidget);

  8. ...

  9. }

  10. }

  11. }

对于child和newWidget都不为空的情况,Widget.canUpdate用于判断当前子Element是否可以更新,而不是通过重新创建来更新。

  1. static bool canUpdate(Widget oldWidget, Widget newWidget) {

  2. return oldWidget.runtimeType == newWidget.runtimeType

  3. && oldWidget.key == newWidget.key;

  4. }

我们可以看到Widget.canUpdate的定义,是通过比较runtimeType和key来判断的; 如果可以更新,则更新Element子节点; 否则,停用子节点的 Element,并基于 newWidget 创建新的 Element。

如何自定义小部件

第一个版本设计

在第一个版本的设计中,我们认为它相对简单。 所有组件都继承自Object,实现一个build方法,根据DSL转换后的nodeData设置Widget的属性:

技术支持工程师简历模板_技术支持工程师简历怎么写_简历模板师工程支持技术怎么写

让我们看一个简单的例子。 让我们考虑一下最坏的情况。 第一个节点具有 match_content 属性。 每次创建Widget时,我们都需要计算布局:

技术支持工程师简历模板_技术支持工程师简历怎么写_简历模板师工程支持技术怎么写

这样,每次更新Widget并计算顶部节点的大小时,都必须深度遍历整棵树。 如果 Widget 节点之一更新会发生什么?

技术支持工程师简历模板_技术支持工程师简历怎么写_简历模板师工程支持技术怎么写

答案是重新计算一切,因为 Widget 是不可变的,并且不断地被重新创建和销毁。 最坏的情况下,会达到O(N2)。 你可以想象一长串的表现会如何。

当前版本设计

第二个版本,我们选择自定义Widget、Element和RenderObject; 下面是我们一些组件的类图。

技术支持工程师简历怎么写_简历模板师工程支持技术怎么写_技术支持工程师简历模板

虚线框是我们自定义的Widget组件。 从上图可以看出,我们定制的Widget大致分为三种:

在自定义的RenderObject中,对于click事件和paint方法没有做特殊的处理,都是由组合的Widget来处理。

  1. @override

  2. bool hitTestChildren(HitTestResult result, {Offset position}) {

  3. return child?.hitTest(result, position: position) ?? false;

  4. }


  5. @override

  6. void paint(PaintingContext context, Offset offset) {

  7. if (child != null) context.paintChild(child, offset);

  8. }

如何处理match_content

当前节点的宽度和高度设置为match_content。 需要先计算子节点的大小,然后再计算当前节点的大小。

在实现自定义RenderObject时,我们需要重写performLayout方法; 在performLayout方法中,主要需要做的事情有:

下面以child的情况为例(如Padding)。 在RenderObject中,对于具有match_content属性的节点,在调用子布局方法时,将parentUsesSize设置为true; 然后根据child.size设置大小。

这样做的好处之一是,当子级的大小发生变化时,父级会自动设置为 needLayout。 由于父级被标记为needLayout,因此它将在当前Frame的Pipline中重新布局和绘制。 当然,这也会造成性能损失,需要特别注意。

  1. @override

  2. void performLayout() {

  3. assert(callback != null);

  4. invokeLayoutCallback(callback);

  5. if (child != null) {

  6. child.layout(constraints, parentUsesSize: true);

  7. size = constraints.constrain(child.size);

  8. } else {

  9. size = constraints.biggest;

  10. }

对于多个children的情况,可以参考RenderSliv​​erList的内部实现。

如何处理match_parent

如果当前节点的宽高设置为match_parent,则尝试扩展到父节点的大小; 这种情况下,当Constraints向下传递的时候,根据父节点的约束,自己的大小就已经知道了,不需要子节点的计算; RenderObject中为我们提供了一个属性sizedByParent,默认为false。 如果该属性设置为match_parent,我们会将当前RenderObject的sizedByParent设置为true; 这样,当Constraints传递下来的时候,子节点就已经知道自己的大小了,不需要进行布局计算。 ,从而提高性能。

在RenderObject中,当sizedByParent设置为true时,需要重载performResize方法:

  1. @override

  2. void performResize() {

  3. size = constraints.biggest;

  4. }

这里需要注意的一点是,这种情况下,重载performLayout方法时,不要再次设置尺寸。

如果绑定的数据发生变化,改变sizedByParent后,一定要调用markNeedsLayoutForSizedByParentChange方法,将当前节点及其父节点设置为needsLayout,重新计算布局,重绘。

计划前后对比

在第二版设计中,渲染一个Widget需要什么样的计算过程?

技术支持工程师简历怎么写_技术支持工程师简历模板_简历模板师工程支持技术怎么写

同样的场景,在RenderObject中,通过performLayout方法向下传递Constraints,计算出child的大小,向上传递。 最终一次遍历即可完成整棵树的布局计算。

如果是上面更新的场景呢?

技术支持工程师简历模板_简历模板师工程支持技术怎么写_技术支持工程师简历怎么写

根据我们上面讲的Element更新流程以及RenderObject的RelayoutBoundary优化,可以看出,随着新的Widget属性变化,Element Tree不需要重新构建,而是更新当前Element节点。 RenderObject在RelayoutBoundary的优化下需要更少的布局计算。

影响

新方案优化后,长列表滑动平均帧率从28提升到50左右。

当前的问题

目前,我们在自定义Widget的实现上还存在问题。 如果仔细看上面performLayout的实现,当我们调用每个child的layout方法时,parentUsesSize被设置为true; 事实上,只有当当前节点属性为match_content时才需要这样做。 我们现在的处理过于简单,导致RelayoutBoundary的优化没有得到真正的享受。 所以目前的实际情况是Widget的每次更新都会导致2N次Layout计算。 这就是帧率达不到Flutter页面的原因之一,这也是我们下一步需要解决的问题。

外表

目前我们已经实现了DSL到Widget的映射,使得Flutter动态模板渲染成为可能。 DSL 是一种抽象,XML 只是其中一种选择。 未来,在不断提升性能的同时,整个解决方案的抽象也将得到提升,支持通用的DSL转换,沉淀出一套通用的解决方案,通过技术更好地赋能技术。 可以做生意。

从 DSL 到 Widget 的转换只是该过程的一部分。 从模板编辑、本地验证、CDN交付、灰度测试、在线监控等到整个闭环,还有很多地方需要不断打磨和完善。

参考文章

闲鱼团队在Flutter+Dart FaaS前后端融合新技术方面处于行业领先地位! 社区招聘客户端/服务器Java/架构/前端/质量工程师,总部位于杭州阿里巴巴西溪园区。 让我们共同努力,打造有创意空间的社区产品,深度打造顶级开源项目,拓展技术边界,以实现最终目标!

*将简历发送至小闲鱼 → guicai.gxy@alibaba-inc.com

技术支持工程师简历怎么写_简历模板师工程支持技术怎么写_技术支持工程师简历模板

更多系列文章、开源项目、关键见解、深度解读

请寻找闲鱼科技