本文旨在记录学习 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
方法来修改。
从下面这样一个例子入手:
要实现从第一种状态到第二种状态的变更,通常需要使用选择器 findViewById
或类似函数获取到 ViewB 的实例 b
,并调用相应的方法:
1 | // Imperative style |
Declarative UI
声明式 UI 相对来说减轻了开发者的负担,不需要考虑如何调用 UI 实例的方法来改变不同的状态,只需要开发者描述当前的 UI 状态 (即各属性的值),框架会自动将 UI 从之前的状态切换到开发者描述的当前状态。
在声明式 UI 框架中,UI 配置是不可变 (Immutable) 的,它只负责描述当前 UI 应有的样子。要变更 UI 时,UI 配置会在自身触发重建并构造一个新的配置。
还是上面的例子,使用声明式 UI 来实现的话会相对简洁:
1 | // Declarative style |
渲染过程
那么在 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 层,每一层都依赖于下一层,其大致结构如下:
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
之上,根据Android
和iOS
的不同设计风格提供了对应的组件实现,是开发中用到最多的一层
在开发过程中,将不同的 Widget
堆叠到一起可以实现各种各样丰富的效果,比如实现一个文字居中的 HelloWorld 程序,可以这样写:
1 | void main() => runApp( |
Flutter 三棵树
在上面的例子中,咱们其实得到的就是一棵 Widget 树
,Flutter
会将上面的代码整合成如下的数据结构:
如果需要改变背景色,可以在 Widget 树
中再增加一个 Widget
:
1 | void main() => runApp( |
最终得到这样一棵 Widget 树
及其最终呈现的效果:
至此,可以看到,咱们写的所有代码都是以 Widget
形式呈现的,在 Widget
的官方文档中,开头就有这样一句话:
A widget is an immutable description of part of a user interface.
这句话传递了两个信息:
Widget
是指一部分 UI 的描述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
同时持有 Widget
和 RenderObject
的引用,如下图:
在需要更新 UI 的时候,Flutter
会遍历三棵树,从 Widget 树
中需要更新的子树开始,对比每个子节点 Widget
的 runtimeType
和 key
,这时会有两种情况:
runtimeType
及key
的值相同,则认为是同一个Widget
,Flutter
会复用其对应的Element
和RenderObject
节点,只更新RenderObject
的属性值,最终从Render 树
中找到渲染对象并将其更新。其演示图如下:
runtimeTpe
或key
的值不同,则认为不是同一个Widget
,不可复用,需要重新创建对应的Element
和RenderObject
。其演示图如下:
至此,Flutter
的三棵树就形成了,有一点需要注意的是,Widget
包括纯展示型和组合型,纯展示型 Widget
就是指最基础的 RenderObjectWidget
,如 Center
、Text
等,而组合型 Widget
有两种,分别是 StatefulWidget
和 StatelessWidget
,这两个 Widget
相当于是 RenderObjectWidget
的包装类,它们在生成树时略有不同,因为它们没有对应的 RenderObject
,如下图:
以上就是整个 Build
过程了,在 Flutter
的世界里,有一个很重要的原理,即 Aggressive Composability
,意思是在 Flutter
中,要实现一个复杂的 UI,都是通过组合各种更基础的 Widget
来实现的。在递归遍历 Widget 树
的时候,触底条件为遍历到 RenderObjectWidget
,这意味着在遇到最基础的 Widget
时结束遍历。由于 UI 是由各种 Widget
组合实现的,所以随着 UI 复杂性的提升,树的层级也会越来越深,为了提升性能,Flutter
在接下来的步骤里做了优化。
Layout
Flutter
的渲染过程遵循一个原则: 简单即高效。主要体现在以下几方面:
遍历
Render 树
时只做一次深度优先遍历,在遍历过程中传递数据,实现亚线形时间复杂度的布局和绘制;采用盒子约束模型 (BoxConstraints) 布局,可以生成各种复杂的布局方案;
利用图层合成技术实现结构性重绘,即只重绘有需要的子树,充分利用当代硬件的图层合成能力。
RenderObject
虽然 Flutter
有三棵树,但是在 Layout
及以后过程中,真正执行遍历操作是在 Render 树
上。所以,Render 树
上的节点 - RenderObject
有着举足轻重的作用。
RenderObject
是 Rendering
层的一个抽象类,主要提供了一个关于 Layout
的抽象概念,其几大特性如下:
没有坐标系统
知道父节点,但不知道子节点是谁
虽然不知道子节点是谁,但是知道如何访问子节点。这样一来,就可以不限制子节点模型了,一个
RenderObject
可以拥有一个子节点,也可以拥有多个子节点,因为它知道如何访问这些子节点以抽象的方式进行
Layout
和Paint
。提供了这两种能力,但是不具体实现如何进行使用
parentData
属性存储子节点的位置信息
RenderObject
携带的两个信息穿梭于整个 Layout
过程,Constraints
以及 Sizes
,RenderObject
将 Constraints
传给子节点,子节点在完成自己的计算后将 Sizes
回传给 RenderObject
,整个数据流向如下图,Flutter
在一次遍历中完成这种数据传递:
虽然拥有了 Layout
过程的数据流程及大概思路,但是 RenderObject
是一个抽象的概念,所以它并不知道如何去执行具体的操作,所以Flutter
又增加了对其的实现,其中包括 RenderBox
及 RenderSliver
,分别对应普通 Widget
及可滑动的 Widget
。
以 RenderBox
为例,实现了二维笛卡尔坐标系,增加了 Height
和 Width
属性,这样便可以根据父节点传下来的 Constraints
计算自己的大小,从而让父节点来确定自己的位置。
Constraints
前面也说了,Flutter
采用盒子约束模型,也就是这个 Constraints
,而 Layout
过程又依赖于父节点传下来的 Constraints
,那么这个 Constraints
到底是何方神圣呢?
顾名思义,Constraints
就是一种约束。仍然以作用于 RenderBox
的 BoxConstraints
为例,它提供了 minWidth
、maxWidth
、minHeight
、maxHeight
4 个属性,用以对子节点进行约束,接收该 Constraints
的子节点在计算自己的大小时就有了两个条件:
- minWidth <= width <= maxWidth
- minHeight <= height <= maxHeight
在向下遍历子节点时,将自身的 Constraints
传递下去,子节点再将自己的 Constraints
向下传递,递归触底时将计算好的 Size
向上传递,根据所有子节点的 Size
最终确定自身的 Size
再向上传递,递归结束则完成整个 Layout
过程,此时所有节点的大小已经确定了,而节点的位置则由父节点根据每个子节点的大小来确定,也就是说,第一个子节点的位置从父节点的左上角开始,第二个子节点从第一个子节点右上角开始,依此类推。
RelayoutBoundary
至此,我们已经知道了 Layout
的整体过程。思考一下,如果每次 UI 更新都需要从头开始遍历的话,随着 UI 的复杂化,那 Layout
的成本还是很高的,这时 Flutter
引进了一个标记 - RelayoutBoundary
,如下图:
在需要更新时,如果有这个标记,那么在 Layout
过程中就不会对标记以上的节点进行 Relayout
操作,这样一来,每次只需要对一小部分子树进行重新布局了,效率就会提高很多。当然这个标记不需要手动添加,在 Flutter
提供的 Widget
中已经根据实际情况做好了设置,这种实际情况就是,如果某个节点的子树的大小由该节点决定的话,就会添加 RelayoutBoundary
。例如,该节点向下传递的 Constraints
为 tight
类型,即 minWidth = maxWidth
且 minHeight = maxHeight
,子节点无法自行调节大小,因为可选范围被固定了。
Paint
经过 Layout
过程之后,剩下要做的就是把每个节点绘制出来,理想情况就是从根节点开始依次遍历根据父节点给定的偏移量进行绘制就行了,因为不涉及大小、定位等问题,只需要执行 paint
操作。其间的数据流向应该如下所示:
但实际情况往往会更复杂一些。假设遇到下图的情况:
视频内容来自于系统提供的不需要和用户进行交互的硬件,Flutter
只负责将其提供的 Texture
进行绘制,但如果需要给整个画面添加一个背景,而且在视频内容上层添加操作控件,例如暂停、播放之类的。这就意味着需要将整个画面拆分为不同的图层进行绘制了,最终再将图层进行组合合成形成我们希望的视觉效果。
从上面的例子来讲,在绘制 Flutter
进行深度优先遍历,依次绘制视频背景层,即蓝色节点,之后绘制视频内容层时,为了正确绘制这些 Texture
,Flutter
将内容绘制到一个独立的图层,即黄色节点,在以次绘制完 1、2、3、4 节点后,回到第 2 个节点,发现在这个节点还需要绘制更多的内容,这时又添加了一个新的独立图层,在这个新的图层上继续绘制剩余的内容,在这种情况下,除了父节点向下传递偏移量 Offset,子节点还需要把目标图层传回给父节点,这时数据流向图是这样的:
RepaintBoundary
从上图可以看到,如果黄色图层的内容对绿色图层有影响,那么当黄色图层重绘时,节点 5、6 都需要重绘,而实际上节点 6 与黄色节点 4 并没有相互关系,这样就会造成多余的重绘操作了,而且在这种影响下,可能节点 2 也需要被重绘,那原则上相当于整棵树都需要重绘,这与 Layout
过程遇到的问题是类似的,解决方案也类似,如果只想针对局部子树进行重绘,只需要在这棵子树添加一个标记 - RepaintBoundary
,这样一来,重绘操作就只发生在相关的子树中了,这时最终的图层结构如下,RepaintBoundary
前后节点的目标图层都相对独立,互不影响:
同样的,Flutter
提供的 Widget
也都做好了正确的 RepaintBoundary
标记,除非完全自定义一个复杂的 Widget
,否则不需要开发者考虑手动添加 RepaintBoundary
。
Composite
在 Paint
过程中产生了很多图层 (layers),在 Composite
过程中会将多个小碎片组合合成为一个新的图层,比如上面例子中的 节点 2 和节点 5,通过这些图层可以快速的更新 UI,因为 Paint
过程确定了某个图层应有的样子,可以通过改变图层的偏移量来更新 UI,而不需要再次遍历 Render 树
。这一点再滚动类 Widget
的体现尤为明显。
假设滚动有一个可滚动列表如上,假如不复用图层的话,每滚动 1 像素都需要进行一次 Relayout
和 Repaint
,这消耗是非常大的,所以 Flutter
将每个 Item 都合成到一个独立的图层,如图中每种颜色代表一个图层,这样一来,在滚动的时候只需要改变每个图层的偏移量,而不需要进行 Relayout
了,而且只需要生成有限个图层就可以实现无限长度的滚动列表,因为被移出屏幕的图层可以被重新利用,这种图层复用机制在很大程度上提升了 Flutter
的性能。
至此,已经从 Framework 层面梳理完 Flutter
的渲染过程了。在日常开发中,开发者应该多加留意各个 Widget
的 Constraints
,因为这关系到 UI 最终呈现出来的样式,也许 UI 不是你想要的样子只是因为 Constraints
在搞怪。