Joey的Flutter之旅 - (3) 布局构建、渲染、绘制机制浅析

Joey的Flutter之旅 - (3) 布局构建、渲染、绘制机制浅析

前言

当我们开发时,如果能理解一个操作背后的意义,底层实现的机理,就容易灵活的设计出更高效的代码结构,所以理解 Flutter 的布局与渲染基本原理对今后的开发会有相当大的帮助。这篇文章的主要依据是 Flutter 官方在 Youtube 讲述的 Flutter 框架基本原理,在此基础上加上了我自己的理解。

从 Flutter 架构说起

Flutter 框架基本架构



可以看到,Flutter 的分层架构比较清晰,上层 Framework 层主要负责组件布局,渲染对象生成,动画交互处理等,采用了 Dart 语言开发。底层 Engine 层包含了 Skia 渲染引擎以及文字处理引擎等。对于 Flutter 开发者来说,引擎层是很难接触到的,平时最经常打交道的是 Framework 层,更具体一点呢,应该是 Widgets 层及以上,我们只需要把这些层面的开发做好优化就可以了。而不需要去直接干涉 Rendering 层如何生成 Render Object Tree 等等,因为这些是 Flutter 开发团队的工作。

Flutter 布局渲染基本流程



这是 Flutter 布局渲染的整体流程,即用户在作出操作后,Flutter 会有一个动画响应过程,伴随着 Widget 的构建生成基本的视图描述数据,之后的渲染阶段会根据之前的描述数据生成具体的渲染对象,然后绘制图层,由于直接交付给 GPU 多图层视图数据是低效率的,所以还需要进行一步图层的合成,最后交由引擎负责光栅化视图。由于底层渲染过程不在我们一般开发者需要掌握的范围之内,所以本文涉及到的主要是 Framework 层的布局与渲染流程。

布局构建基本原理

关于 Flutter 布局构建部分,我想说的是,如果你曾经深入学习过 React 框架,那么你会感到非常熟悉,非常亲切,因为 Flutter 就是受 React 设计思想启发的一种“响应式框架”,其中绝大部分原理是一致的。当然如果之前没有接触过 React 也没关系,上一篇介绍 Widget 的文章中我顺带着将“响应式”编程思想写了一下,可以回过头看一下,我相信这会对理解 Flutter 布局构建原理有很大帮助。

认识几棵“树”



之前讲到了 Widget 这是对于视图的一种“描述数据”,说到底他仅仅是配置信息,而不是真正的视图数据,但是仅有这些数据是不足以让引擎知道如何渲染出界面的,所以我们需要将 Widget 转化为能用来渲染视图的 Render Object,即“渲染对象”。左右两边的这两棵树都好理解,那么中间的 Element Tree 是干什么的呢?

我们先要认识一个浏览器的技术 —— “虚拟DOM”。“虚拟DOM”解决了一个重要的矛盾,就是 DOM 操作的性能损耗与想要实现局部 DOM 操作的矛盾,比如我们想要通过 js 或 jQuery 对网页上几个元素进行更新,按照正常的思维应该是更新完所有数据之后,DOM 更新一次,但是实际上 DOM 会在每一次元素更新到来之时渲染一次 DOM,这种性能开销是非常大的,所以经常听到的说法是“尽量不要去操作 DOM”。针对这种情况,“虚拟DOM”出现了,针对上面的例子,“虚拟DOM”会先汇总各个元素的更新情况,通过“diff算法”计算出与原来 DOM 树的差异,最后通过一次 DOM 更新解决,这样的设计,使得性能大大提高。

Flutter 的 Element Tree 即是充当着虚拟 DOM 的作用,帮助视图更高效地完成构建工作。这里的 Element 由 Widget 创建,如果说 Widget 是视图的“描述信息”,那么 Element 则是根据当前“描述信息”创建的结构化信息,里面包含了各种部件的上下文信息。

布局构建的基本原理

我们以官方的一个例子来展开 Flutter 布局构建的基本流程,主要介绍从 Widget 创建到渲染树建立的整个流程:

第一步,如上图,创建两个 Widget,一个绿色的 Rectangle Widget,他携带一个 Child:一个蓝色的 Circle Widget,往下看

第二步,Rectangle Widget 调用 createElement() 方法创建了自己对应的 Element。

第三步,Rectangle Widget 通过 createRenderObject() 创建了相应的 Render Object —— “RenderRectangle”,同时,Element 持有了两者的引用,将三者联系起来,这很重要。

第四步,子 Widget 即蓝色的 Circle Widget 调用 createElement() 方法创建了自己对应的 Element,通过 mount() 方法将 Element 挂载到了父 Element 上。

最后,和之前一样,蓝色 Circle Widget 的 Render Object 也创建完成,Element 通过自己的 attachRenderObject() 方法将 RenderCircle attach 到了 RenderRectangle 的一个 slot (插槽) 上。同时,Element Tree 也已经持有了 Widget 与 Render Object 的引用。

至此,渲染树建立完毕。

Widget 变化对布局的影响

作为一开始,先介绍两种情形,回顾前面的知识我们知道,Widget 是不可变对象,当视图的“描述数据”要发生改变时,Widget 需要重建,但是我们知道,新的 Widget 并不一定会改变类型,如果仅仅是重建了一个改变原有属性的 Widget,和一个类型发生变化的 Widget,他们对于布局的重建有着怎样不同的影响呢?

情形1:新 Widget 类型不变

可以看到,这一种情形中,新的 Rectangle 和 Circle 仅仅是颜色这一属性发生了变化,Widget 的类型并没有变化,那么 Flutter 的布局会如何响应?

可以看到,黄色 Rectangle Widget 并没有新建 Element,而是复用了之前 Rectangle 的 Element,由于 Rectangle 并没有改变 Widget 类型,所以 Element 只需要根据新的 Widget 修改自身的颜色配置参数作为新的 Widget 即可。有些朋友会很差异,为什么 Element 可以改变?其实,之前强调了很多次 Widget 是不可变对象,但是可从来没人说过 Element 和 Render Object 也是不可变对象,注意,他们两个是可变对象!正是通过这种可变与不可变对象的组合,才构成了 Flutter 灵活而又高效的布局模式。

所以,这一步就很容易理解了,相应的,RenderRectangle 也只是做了颜色数据上的改变,并没有重新建立 Render Object。

最终,情形 1 是通过 Widget 的重建以及对应 Element 以及 Render Object 的修改完成了渲染树的重建,之后会进行具体的重绘过程。

情形2:Widget 类型改变

我们看到,在这一种情形中,子 Widget 类型由 Circle 变为了 Triangle,这种情况下,Flutter 的布局重建会有怎样的变化呢?

首先看第一步,由于 Rectangle Widget 类型并没有变化,所以并没有引起 Element 与 Render Object 的实际变化。Element 照常持有了新 Rectangle Widget 与 RenderRectangle 的引用。

但是此时的 Triangle Widget 由于类型发生了变化,便不能和之前一样复用 由 Rectangle 类型创建的 Element 与 Render Object 了。

此时的 Element 与 Render Object 被“摘”下来,值得一提的是,Flutter 仍可以复用他们,只不过在这一流程中,他们被抛弃了。

然后,Triangle Widget 重新走了一遍 createElement()createRenderObject() 的流程,并且分别挂载到了父节点的 slot 上面。新的 Element 再次持有了 Triangle Widget 与 RenderTriangle 的引用。

布局重建的新思考

上面几种布局重建,需要调用 runApp() 方法进行,而且这种方式会从根节点开始进行重建,上面的例子 Widget 数量很少,所以影响不大,但是如果成百上千个 Widget,那么每次将整个 tree 重建就相当不划算了,我们希望 Flutter 能够实现局部的布局重建,于是新的方法出现:

引入一个 StatefulBuilder,它很特殊,首先,我们可以看到没有与他对应的 Render Object,其次,StatefulBuilder 没有实际的 child 节点,它的内部有一个 builder 回调:

child: new StatefulBuilder(
    builder: (..., StateSetter setState){

    // setup your state, whenever it changes, call setState()

    return new ChildWidget(...);    
    }
)

当获得新的 ChildWidget 时,Element 捕获使用来自回调的返回值,然后,就“像”得到了一个 Child 节点一样,但是这不是 StatefulBuilder 的 Child。

在上面的 builder 回调函数里面有个 setState 函数作为参数出现,调用 setState 会重建当前调用位置的子树,当 Triangle Widget 变为 Square Widget 时,setState 函数调用,对应的 Element 和 Render Object 会重建,但是注意了,这一次重建开始的位置是从 StatefulBuilder 往下的位置,上面的 Rectangle 并没有受影响,所以这种方式实现了布局的局部重建。

上面我们看到了 Triangle Widget 转换成 Square Widget 实现了局部重建。现在我们思考一个问题,如何去维持这么一个变化的 state,放在全局变量中?如果有很多 state 需要维持的话,那么全部堆积在全局变量中明显是不好的,我们希望专门分离出一个对象去有效地管理 State,往下看。

于是,熟悉的 StatefulWidget & State 的形式被设计出来了,StatefulWidget 并不是 RenderObjectWidget,也就是说,他不是用来形成具体的渲染对象的,他只是用来维持特定的 state,所以右边的渲染树中你找不到 StatefulWidget 的影子。state 是由 StatefulWidget 创建的,而其对应的 StatefulElement 会持有这个 state,如上图所示。

StatelessWidget 与 StatefulWidget 构建规则

这两种 Widget 开发者经常与其打交道,他们都含有自己的 build 方法,那么我们再来探讨一下他们 build 遵循的规则。

首先是 StatelessWidget,到现在大家应该都知道了,StatelessWidget 不持有 state,不能调用 State.setState(),但是并不意味着他就无法触发 build,不然他的 build 方法就成了摆设。下面是官方文档对于 StatelessWidget 执行 Build() 的三种情况描述:

The [build] method of a stateless widget is typically only called in three situations: the first time the widget is inserted in the tree, when the widget's parent changes its configuration, and when an [InheritedWidget] it depends on changes.

即:

  1. 当第一次插入到 Widget tree 中时;
  2. 当父结点的配置信息发生变化;
  3. 当 StatelessWidget 依赖的 [InheritedWidget] 发生变化;

上面这三种情况发生时,当前的 StatelessWidget 执行 Build() 方法。

然后说 StatefulWidget,由于它关联了 State,它的 rebuild 与 State 息息相关,当 StatefulWidget 关联的 State 发生改变时,通过调用 State.setState() 通知 framework 它内部状态的改变,标记发生改变的Widget 对应的 Element 为 “dirty”,并添加进 dirty list,Flutter 会将这些发生状态改变的 Widget 执行 build() 进行重新构建。

布局渲染与绘制需要注意的几点

上面讲到的布局构建最终只是形成“视图数据”,也就是一棵渲染树,最终展示给用户的视图还要经过渲染树的实际布局及绘制才能生成。

这一部分仅仅选择几个要点展开说一下:

布局 Size 的计算流程

上图表示的是,父渲染对象会将布局约束信息乡下传递,子渲染对象根据自己的渲染情况返回 Size,Size 数据会向上传递,最终父渲染对象完成布局过程。

重布局边界(Relayout boundary)

为避免某一渲染对象重布局时触发父级对象的重布局,减少不必要的性能开销,Flutter 引入了 Relayout boundary 机制。即通过在某一渲染对象上设置重布局边界,避免重布局的影响范围扩散出去,当然,这个其实也不需要程序员亲自去做,满足下面条件之一的,会自动设置重布局边界:

  1. constraints.isTight 父对象对子对象 size 严格限定
  2. parentUsesSize == false 父对象尺寸与子对象尺寸无依赖
  3. sizedByParent == true 子对象尺寸受父对象约束

满足以上条件之一,重布局的范围就不会扩散出去。

如图,满足第一种情况,即父对象对子对象尺寸严格约束,所以子对象自动被设置了布局边界,子树的重布局不会扩散到父级。

图层绘制 与 重绘边界(Repaint boundary)

渲染对象完成 Layout 之后,Render Tree 上的各个对象的 Size 和 Position 数据已经明确,然后开始图层的绘制流程,绘制的目的是形成一个 Layer Tree 交付底层引擎。之前讲到的 Size 的计算是从下往上的,而图层的绘制是从上往下开始的。

如图,当绘制到左边第二层结点时,由于 4 结点需要单独绘制在一个图层,所以这个结点的背景与前景绘制到了绿色及红色图层,此时出现了一种尴尬的状况,即,前景 5 和另一棵子树的 6 被绘制到了一个图层上,假如 5 或 6 任意一个发生重绘的时候,另一个也会被重绘,这是毫无意义的,那么如何解决这一问题呢?

没错,就像前面避免重布局范围蔓延的方式类似,Flutter 引入了重绘边界(Repaint boundary)的概念,它的原理是,在需要分隔图层的结点位置设置重绘边界,绘制流程经过边界时会被强制切换新的图层,避免无关内容置于同一图层引起不必要的重绘。


参考资料

  1. The Mahogany Staircase - Flutter's Layered Design
  2. Flutter's Rendering Pipeline
  3. How to Create Stateless Widgets - Flutter Widgets 101 Ep. 1
  4. How Stateful Widgets Are Used Best - Flutter Widgets 101 Ep. 2
  5. 深入了解Flutter界面开发
  6. Flutter的原理及美团的实践(上)
  7. Flutter视图的Layout与Paint

转载请注明出处,谢谢

编辑于 2018-12-19 13:36