0%

浅析 Flutter 渲染原理

本文旨在记录学习 Flutter 在 Framework 层面的渲染原理时的所得,通过对整个渲染过程的整理,可以有效的理解 Widget 的行为,高效完成 UI 实现。

UI 编程的两种方式

从 Web 到 Android 和 iOS,大家普遍熟知的便是命令式 UI 编程风格 (Imperative UI),随着近年来 UI 框架的飞速发展,声明式 UI 编程风格 (Declarative UI) 逐步进入大家的视野。Flutter 则采用了声明式 UI 的思想来实现 UI。

Imperative UI

所谓命令式 UI,即在实现 UI 时创建带有各种属性和方法的 UI 实例,如 UIView,随后在 UI 发生变更时调用这个实例对应的方法或属性的 setter 方法来修改。

从下面这样一个例子入手:

uiChanges

要实现从第一种状态到第二种状态的变更,通常需要使用选择器 findViewById 或类似函数获取到 ViewB 的实例 b,并调用相应的方法:

1
2
3
4
5
6
// Imperative style
ViewB b = findViewById();
b.setColor(red);
b.clearChildren();
ViewC c3 = new ViewC();
b.add(c3);

Declarative UI

声明式 UI 相对来说减轻了开发者的负担,不需要考虑如何调用 UI 实例的方法来改变不同的状态,只需要开发者描述当前的 UI 状态 (即各属性的值),框架会自动将 UI 从之前的状态切换到开发者描述的当前状态。

在声明式 UI 框架中,UI 配置是不可变 (Immutable) 的,它只负责描述当前 UI 应有的样子。要变更 UI 时,UI 配置会在自身触发重建并构造一个新的配置。

还是上面的例子,使用声明式 UI 来实现的话会相对简洁:

1
2
3
4
5
// Declarative style
return ViewB(
color: red,
child: ViewC(...),
)

渲染过程

那么在 Flutter 中,是如何把这种声明式的配置渲染到屏幕上的呢?首先看下 Flutter 渲染过程的整体步骤。

Flutter 的整个渲染步骤如下:

UserInput -> Animation -> Build -> Layout -> Paint -> Composite -> Rasterize

从接收用户输入事件到将 UI 绘制到屏幕上需要经过 7 个步骤:

  • UserInput: 响应用户输入事件,如点击、长按、拖拽等
  • Animation: 根据用户输入事件确定动画效果
  • Build: 开始构建 Widget,将 UI 配置转换成可渲染的数据结构
  • Layout: 确定每个 Widget 的位置和大小
  • Paint: 将 Widget 绘制成用户看到的样子,生成图层或者 Texture
  • Composite:Paint 过程生成图层或纹理按照顺序进行组合合成,以便可以高效的将 Widget 呈现到屏幕上
  • Rasterize:Composite 过程合成的图层映射成物理像素显示到屏幕上

本文将关注 Build -> Composite 这 4 个过程。

Build

在这个过程,Flutter 会将声明的 Widgets 进行结构化,最终整理成后续步骤能够处理的数据结构 - 树。经过 Build 过程,最终会得到三棵树: Widget tree、Element tree 和 Render tree。在进一步了解之前,先来看看 Flutter 的 UI 库。

UI 分层系统

在 UI 库方面,像整体架构分层一样,Flutter 对整个 UI 系统也有一个分层设计,通过上层对下层的不断封装,最终呈现给开发者的就是一个方便使用的 Widget 库。Flutter 的 UI 系统分为 4 层,每一层都依赖于下一层,其大致结构如下:

uiLayers

  • dart:ui: 这是 Dart 提供的 UI 库,主要是对 Engine Api 的封装,提供 Raw canvas、Raw text layout、Dart core api 等,用于实现最基础的 UI 绘制,灵活性和可控性极高,但由于都是比较原始的 Api,所以相对来说也比较繁琐。可参考一个简单的 RawHelloWorld Demo,需要手动计算大小和位置,样板代码过多。

  • Rendering:dart:ui 层的简单封装,提供了布局抽象功能,在 UI 实现方面比 dart:ui 简便很多,可参考简单的 RenderingHelloWorld Demo。但是在输入事件的处理上还是需要较为繁琐的捕获触摸点,判断点击区域等计算,可参考简单的 RenderingInput Demo

  • Widgets:Rendering 层的封装,提供了组合抽象的能力,Rendering 层的每一个类在 Widgets 层都有一个与之对应的实现类,例如: Rendering 层有一个 RenderObject,在 Widgets 层有一个 RenderBox 与之对应,提供了更为具体的能力。

  • Material、Cupertino:Widgets 之上,根据 AndroidiOS 的不同设计风格提供了对应的组件实现,是开发中用到最多的一层

在开发过程中,将不同的 Widget 堆叠到一起可以实现各种各样丰富的效果,比如实现一个文字居中的 HelloWorld 程序,可以这样写:

1
2
3
4
5
6
7
8
void main() => runApp(
const Center(
child: Text(
'Hello World!',
textDirection: TextDirection.ltr,
),
),
);

Flutter 三棵树

在上面的例子中,咱们其实得到的就是一棵 Widget 树Flutter 会将上面的代码整合成如下的数据结构:

simpleWidgetTree

如果需要改变背景色,可以在 Widget 树中再增加一个 Widget:

1
2
3
4
5
6
7
8
9
10
11
void main() => runApp(
Container(
color: Color(0xFFFF6117),
child: const Center(
child: Text(
'Hello World!',
textDirection: TextDirection.ltr,
),
),
),
);

最终得到这样一棵 Widget 树 及其最终呈现的效果:

normalWidgetDemo

至此,可以看到,咱们写的所有代码都是以 Widget 形式呈现的,在 Widget 的官方文档中,开头就有这样一句话:

A widget is an immutable description of part of a user interface.

这句话传递了两个信息:

  1. Widget 是指一部分 UI 的描述
  2. Widget 是不可变的 (Immutable)

第一个信息显而易见,各个 Widget 会根据我们所写的属性 (在 Flutter 里指状态 - State) 展示在屏幕上。那么第二个信息,如果 Widget 是不可变的,而且用的是声明式 UI,那么随着用户的操作或者数据的变更,UI 是怎么更新的呢?其实在 Flutter 中,除了 Widget 树,还有 Element 树Render 树,这三棵树各司其职,完成了 Flutter 的渲染更新。其主要功能如下:

Widget Element RenderObject
Element 的配置描述

持有属性值
提供公共 Api
Widget 在树中特定位置的一个实例

在 UI 结构中保留位置
管理父级/子级的关系
Render 树中的对象,主管渲染

测量自身 Size 并进行绘制
放置子节点
声明输入事件

在配置好 Widget 后,Flutter 会生成一个对应的 Element,而 Element 又会调用 Widget 的方法生成一个 RenderObject, 依此类推,这样就生成了三棵树,Element 同时持有 WidgetRenderObject 的引用,如下图:

treeRelationship

在需要更新 UI 的时候,Flutter 会遍历三棵树,从 Widget 树 中需要更新的子树开始,对比每个子节点 WidgetruntimeTypekey,这时会有两种情况:

  1. runtimeTypekey 的值相同,则认为是同一个 WidgetFlutter 会复用其对应的 ElementRenderObject 节点,只更新 RenderObject 的属性值,最终从 Render 树 中找到渲染对象并将其更新。其演示图如下:

updateUIWithTheSameWidget

  1. runtimeTpekey 的值不同,则认为不是同一个 Widget,不可复用,需要重新创建对应的 ElementRenderObject。其演示图如下:

updateUIWithDiffWidget

至此,Flutter 的三棵树就形成了,有一点需要注意的是,Widget 包括纯展示型和组合型,纯展示型 Widget 就是指最基础的 RenderObjectWidget,如 CenterText 等,而组合型 Widget 有两种,分别是 StatefulWidgetStatelessWidget,这两个 Widget 相当于是 RenderObjectWidget 的包装类,它们在生成树时略有不同,因为它们没有对应的 RenderObject,如下图:

statefulTree

statelessTree

以上就是整个 Build 过程了,在 Flutter 的世界里,有一个很重要的原理,即 Aggressive Composability,意思是在 Flutter 中,要实现一个复杂的 UI,都是通过组合各种更基础的 Widget 来实现的。在递归遍历 Widget 树 的时候,触底条件为遍历到 RenderObjectWidget,这意味着在遇到最基础的 Widget 时结束遍历。由于 UI 是由各种 Widget 组合实现的,所以随着 UI 复杂性的提升,树的层级也会越来越深,为了提升性能,Flutter 在接下来的步骤里做了优化。

Layout

Flutter 的渲染过程遵循一个原则: 简单即高效。主要体现在以下几方面:

  1. 遍历 Render 树 时只做一次深度优先遍历,在遍历过程中传递数据,实现亚线形时间复杂度的布局和绘制;

  2. 采用盒子约束模型 (BoxConstraints) 布局,可以生成各种复杂的布局方案;

  3. 利用图层合成技术实现结构性重绘,即只重绘有需要的子树,充分利用当代硬件的图层合成能力。

RenderObject

虽然 Flutter 有三棵树,但是在 Layout 及以后过程中,真正执行遍历操作是在 Render 树 上。所以,Render 树 上的节点 - RenderObject 有着举足轻重的作用。

RenderObjectRendering 层的一个抽象类,主要提供了一个关于 Layout 的抽象概念,其几大特性如下:

  • 没有坐标系统

  • 知道父节点,但不知道子节点是谁

  • 虽然不知道子节点是谁,但是知道如何访问子节点。这样一来,就可以不限制子节点模型了,一个 RenderObject 可以拥有一个子节点,也可以拥有多个子节点,因为它知道如何访问这些子节点

  • 以抽象的方式进行 LayoutPaint。提供了这两种能力,但是不具体实现如何进行

  • 使用 parentData 属性存储子节点的位置信息

RenderObject 携带的两个信息穿梭于整个 Layout 过程,Constraints 以及 SizesRenderObjectConstraints 传给子节点,子节点在完成自己的计算后将 Sizes 回传给 RenderObject,整个数据流向如下图,Flutter 在一次遍历中完成这种数据传递:

layoutDataFlow

虽然拥有了 Layout 过程的数据流程及大概思路,但是 RenderObject 是一个抽象的概念,所以它并不知道如何去执行具体的操作,所以Flutter 又增加了对其的实现,其中包括 RenderBoxRenderSliver,分别对应普通 Widget 及可滑动的 Widget

RenderBox 为例,实现了二维笛卡尔坐标系,增加了 HeightWidth 属性,这样便可以根据父节点传下来的 Constraints 计算自己的大小,从而让父节点来确定自己的位置。

Constraints

前面也说了,Flutter 采用盒子约束模型,也就是这个 Constraints,而 Layout 过程又依赖于父节点传下来的 Constraints,那么这个 Constraints 到底是何方神圣呢?

顾名思义,Constraints 就是一种约束。仍然以作用于 RenderBoxBoxConstraints 为例,它提供了 minWidthmaxWidthminHeightmaxHeight 4 个属性,用以对子节点进行约束,接收该 Constraints 的子节点在计算自己的大小时就有了两个条件:

  • minWidth <= width <= maxWidth
  • minHeight <= height <= maxHeight

在向下遍历子节点时,将自身的 Constraints 传递下去,子节点再将自己的 Constraints 向下传递,递归触底时将计算好的 Size 向上传递,根据所有子节点的 Size 最终确定自身的 Size 再向上传递,递归结束则完成整个 Layout 过程,此时所有节点的大小已经确定了,而节点的位置则由父节点根据每个子节点的大小来确定,也就是说,第一个子节点的位置从父节点的左上角开始,第二个子节点从第一个子节点右上角开始,依此类推。

RelayoutBoundary

至此,我们已经知道了 Layout 的整体过程。思考一下,如果每次 UI 更新都需要从头开始遍历的话,随着 UI 的复杂化,那 Layout 的成本还是很高的,这时 Flutter 引进了一个标记 - RelayoutBoundary,如下图:

relayoutBoundary

在需要更新时,如果有这个标记,那么在 Layout 过程中就不会对标记以上的节点进行 Relayout 操作,这样一来,每次只需要对一小部分子树进行重新布局了,效率就会提高很多。当然这个标记不需要手动添加,在 Flutter 提供的 Widget 中已经根据实际情况做好了设置,这种实际情况就是,如果某个节点的子树的大小由该节点决定的话,就会添加 RelayoutBoundary。例如,该节点向下传递的 Constraintstight 类型,即 minWidth = maxWidthminHeight = maxHeight,子节点无法自行调节大小,因为可选范围被固定了。

Paint

经过 Layout 过程之后,剩下要做的就是把每个节点绘制出来,理想情况就是从根节点开始依次遍历根据父节点给定的偏移量进行绘制就行了,因为不涉及大小、定位等问题,只需要执行 paint 操作。其间的数据流向应该如下所示:

paintDataFlow

但实际情况往往会更复杂一些。假设遇到下图的情况:

paintLayers

视频内容来自于系统提供的不需要和用户进行交互的硬件,Flutter 只负责将其提供的 Texture 进行绘制,但如果需要给整个画面添加一个背景,而且在视频内容上层添加操作控件,例如暂停、播放之类的。这就意味着需要将整个画面拆分为不同的图层进行绘制了,最终再将图层进行组合合成形成我们希望的视觉效果。

paintLayerTree

从上面的例子来讲,在绘制 Flutter 进行深度优先遍历,依次绘制视频背景层,即蓝色节点,之后绘制视频内容层时,为了正确绘制这些 TextureFlutter 将内容绘制到一个独立的图层,即黄色节点,在以次绘制完 1、2、3、4 节点后,回到第 2 个节点,发现在这个节点还需要绘制更多的内容,这时又添加了一个新的独立图层,在这个新的图层上继续绘制剩余的内容,在这种情况下,除了父节点向下传递偏移量 Offset,子节点还需要把目标图层传回给父节点,这时数据流向图是这样的:

paintDataFlowWithLayer

RepaintBoundary

从上图可以看到,如果黄色图层的内容对绿色图层有影响,那么当黄色图层重绘时,节点 5、6 都需要重绘,而实际上节点 6 与黄色节点 4 并没有相互关系,这样就会造成多余的重绘操作了,而且在这种影响下,可能节点 2 也需要被重绘,那原则上相当于整棵树都需要重绘,这与 Layout 过程遇到的问题是类似的,解决方案也类似,如果只想针对局部子树进行重绘,只需要在这棵子树添加一个标记 - RepaintBoundary,这样一来,重绘操作就只发生在相关的子树中了,这时最终的图层结构如下,RepaintBoundary 前后节点的目标图层都相对独立,互不影响:

repaintBoundary

同样的,Flutter 提供的 Widget 也都做好了正确的 RepaintBoundary 标记,除非完全自定义一个复杂的 Widget,否则不需要开发者考虑手动添加 RepaintBoundary

Composite

Paint 过程中产生了很多图层 (layers),在 Composite 过程中会将多个小碎片组合合成为一个新的图层,比如上面例子中的 节点 2 和节点 5,通过这些图层可以快速的更新 UI,因为 Paint 过程确定了某个图层应有的样子,可以通过改变图层的偏移量来更新 UI,而不需要再次遍历 Render 树。这一点再滚动类 Widget 的体现尤为明显。

compositeScroll

假设滚动有一个可滚动列表如上,假如不复用图层的话,每滚动 1 像素都需要进行一次 RelayoutRepaint,这消耗是非常大的,所以 Flutter 将每个 Item 都合成到一个独立的图层,如图中每种颜色代表一个图层,这样一来,在滚动的时候只需要改变每个图层的偏移量,而不需要进行 Relayout 了,而且只需要生成有限个图层就可以实现无限长度的滚动列表,因为被移出屏幕的图层可以被重新利用,这种图层复用机制在很大程度上提升了 Flutter 的性能。

至此,已经从 Framework 层面梳理完 Flutter 的渲染过程了。在日常开发中,开发者应该多加留意各个 WidgetConstraints,因为这关系到 UI 最终呈现出来的样式,也许 UI 不是你想要的样子只是因为 Constraints 在搞怪。

Thanks To