Loading...
墨滴

岛上码农@公众号同名

2021/10/30  阅读:32  主题:橙心

Flutter 组件渲染模式详解

前言

作为一个跨平台的框架,Flutter 的渲染机制和很多混合开发的框架具有很大的不同。目前诸如 React-Native,UniApp,Weex 等框架实际上时在原生 UI 基础上做了一层抽象映射,试图抹平不同平台的差异。大部分的实现时基于 Javascript 与原生进行转译交互,实际渲染还是依赖于原生平台。这种方式的好处是保留了原生 UI 的特性,当然也会带来一个很大的缺陷,那就是不同平台的差异性——虽然是同一套代码,但是不同平台运行的界面效果和设计效果不一样

Flutter 的方式与上述的框架不同,实际上的渲染过程不依赖于原生,而是通过 C/C++编写的 Skia 渲染引擎完成界面渲染的。绘制界面的Dart 代码会被编译成原生代码,但是使用的是 Skia 完成渲染。Flutter 内置了 Skia 渲染引擎,使得即便是用户的手机没有更新到最新版本的手机操作系统也能够保持最新的渲染性能。

从交互到 GPU

以一个用户输入为例,整个交互到 CPU 的渲染过程如下图所示,其中框起来的部分就是渲染的过程。

渲染过程.png
渲染过程.png

Build 环节

下面简单的代码片段构建了一个层级简单的组件树。

Container(
  color: Colors.blue,
  child: Row(
    chindren: [
      Image.network('https://www.juejin.com/1.png'),
      const Text('A'),
    ],
  ),
);

当 Flutter需要渲染上面这个组件树时,它会调用 build 方法。build 方法会基于当前 app 的状态返回绘制 UI 的组件树。在这个过程中,build 方法也可以根据状态来决定是否需要引入新的组件。例如在上面的示例代码中,Container 拥有一个 colorchild 属性。从 Container 的源码可以看到,如果它的颜色属性不为空,就会插入一个 ColoredBox 来表示其颜色。

if (color != null) current = ColoredBox(color: color!, child: current);

相应地,ImageText 组件也可能 在构建过程中插入子组件,例如 RawImageRichText。最终的组件树的层级会比代码上的层级更深,如下图所示。这也是为什么使用调试工具(如 Flutter Inspector)时,会发现组件树的层级相对代码的层级深很多。

组件树.png
组件树.png

在构建阶段,Flutter 会将代码的组件转换为相应的元素树(element tree),每个元素对应一个组件(widget)。每个元素代表了树中对应位置的特定组件实例。元素有两种基本类型:

  • ComponentElement:其他元素的宿主(host)。
  • RenderObjectElement:参与布局和绘制的元素。RenderObjectElement是他们组件的中间媒介,是实际的 渲染对象(RenderObject)。BuildContext处理组件树中 widget的位置,因此可以通过BuildContext获取元素的引用。这个 context就是我们调用类似 Theme.of(context)context,会当做参数传递到 build 方法。

上面的组件树转换为元素树后是下面的样子。

渲染过程-组件树-转换元素树.png
渲染过程-组件树-转换元素树.png

由于组件是不可变的,且在节点之间存在父子关系,任何组件树的变化(例如前面的例子中 Text('A')改为 Text('B'))会返回一个新的组件集。但是这并不意味着底层的元素映射必须重建。元素树在显示帧切换的时候会保持,因此元素在性能上显得十分关键——即便在组件树完全销毁的情况允许 Flutter通过缓存的元素映射继续正常运行。因此,只需要检查组件树中的变化,Flutter 可以仅仅对元素树那些需要重新配置的元素做重建操作。

布局和渲染

通常,应用不会只有单个组件。UI 框架很重要的一部分工作就是在绘制到屏幕上前,高效地对组件树进行布局,确定每个元素尺寸和位置。在渲染树中的每个节点的基类是 RenderObject,定义了布局和绘制的抽象模型。每个 RenderObject 知道其父节点,但是对子节点的信息很少知道,也不知道子节点的布局约束。这使得 有效抽象的RenderObject能够处理各种各样的场景。 在构建阶段,对于元素树中继承自 RenderObject每个 RenderObjectElement,Flutter 创建或更新其对象。RenderObject 是一些基础的类:RenderParagragh 负责渲染文本,RenderImage渲染图片,RenderTransform 会在绘制其子元素前做转换操作。上面的例子到了渲染环节后实际渲染的阶段只会渲染那些 RenderObjectElement 对象。

渲染过程-组件树-元素树-渲染树.png
渲染过程-组件树-元素树-渲染树.png

大部分 Flutter 组件是通过继承自 RenderBox的对象进行渲染的,这个对象代表二维笛卡尔坐标系固定尺寸的 RenderObjectRenderBox 提供了盒子约束模型,为每个要被渲染的组件建立最小和最大的宽高。 执行布局的时候,Flutter 使用深度优先遍历(depth-first tranversal)的方式,然后将尺寸元素从父节点到子节点的方式传递下去。为了确定自身的尺寸,子元素必须遵循父节点传递下来的约束。在父节点建立的约束规则内,子元素会向父节点传递其尺寸信息。

渲染过程-约束和尺寸传递.png 完成单次树的遍历后,每个对象在其父节点的布局约束下,都会有一个设定的尺寸,然后就可以调用 paint 方法进行绘制了。

盒子约束模型非常强大,只需要O(n)的时间复杂度就可以完成对象的布局:

  • 父节点可以通过设置最大最小的约束为同一个值规定子元素的尺寸。例如,对于手机 App 来说,最顶级的渲染对象会将其子节点的尺寸设置为屏幕尺寸(子元素可以选择如何利用空间,例如可以在约束条件内设置居中渲染)。
  • 父节点可以规定子元素的宽度然后给子元素动态的高度(或者反过来给指定的高度,然后宽度动态设定)。实际的例子就是文本,可以在横向上满足约束,然后纵向上根据文字的数量动态设定高度。

在子节点对象需要知道还有多少可用空间,从而决定如何渲染自身内容时依然能够发挥作用。通过使用 LayoutBuilder 组件,子节点对象可以检查传递下来的约束,然后决定如何使用他们。例如:

Widget build(BuildContext context) {   
  return LayoutBuilder(
    builder: (context, constraints) {       
      if (constraints.maxWidth < 600) {         
        return const OneColumnLayout();       
      } else {         
        return const TwoColumnLayout();       
      }     
    },   
  ); 
}

所有 RenderObject的根节点时 RenderView,这代表了渲染树的全部输出。当平台请求渲染新的画面帧时,将会调用 compositeFrame 方法,这是渲染树根节点RenderView 的一部分。这会创建一个 SceneBuilder 来触发画面的更新。当场景完成后,RenderView 对象会将组合好的画面传递给在 dart:ui 中定义的 Window.render方法,然后将控制权交给 GPU 去完成画面的绘制。

总结

本篇介绍了 Flutter 渲染工作的基本机制,通过了解渲染机制能够帮助我们了解 Widget 和实际渲染的对应关系,从而在后续的状态管理中更好地理解状态管理工具如何完成组件的更新。接下来我们将开启Flutter状态管理相关的篇章。

关注岛上码农
关注岛上码农

岛上码农@公众号同名

2021/10/30  阅读:32  主题:橙心

作者介绍

岛上码农@公众号同名