Widget、Element、BuildContext 和 RenderObject
Widget
Widget
关键类及其子类继承关系如图所示:
其中,Widget
是Widget Tree
所有节点的基类。Widget
的子类主要分为3类:
-
第1类是
RenderObjectWidget
的子类,具体来说又分为SingleChildRenderObjectWidget
(单子节点容器)、LeafRenderObjectWidget
(叶子节点)、MultiChildRenderObjectWidget
(多子节点容器),它们的共同特点是都对应了一个RenderObject
的子类,可以进行Layout
、Paint
等逻辑。 -
第2类是
StatelessWidget
和StatefulWidget
,它们是开发者最常用的Widget
,自身不具备绘制能力(即不对应Render Object
),但是可以组织和配置RenderObjectWidget
类型的Widget
。 -
第3类是
ProxyWidget
,具体来说又分为ParentDataWidget
和InheritedWidget
,它们的特点是为其子节点提供额外的数据。
Element
Element
的关键类及其子类继承关系如图所示:
从图5-2中可以清楚的看到Element
的继承关系,它实现了BuildContext
接口,图5-2与图5-1相对应,每一个Element
都有一个对应的Widget
。Element
有两个直接的子类 ComponentElement
和 RenderObjectElement
,其中 ComponentElement
的两个子类 StatelessElement
和 StatefulElement
就分别对应了 StatelessWidget
和 StatefulWidget
。
我们知道最终的UI树其实是由一个个独立的Element
节点构成。组件最终的Layout、渲染都是通过RenderObject
来完成的,从创建到渲染的大体流程是:根据Widget
生成Element
,然后创建相应的RenderObject
并关联到Element.renderObject
属性上,最后再通过RenderObject
来完成布局排列和绘制。
Element
就是Widget
在UI树具体位置的一个实例化对象,大多数Element
只有唯一的renderObject
,但还有一些Element
会有多个子节点,如继承自RenderObjectElement
的一些类,比如MultiChildRenderObjectElement
。最终所有Element
的RenderObject
构成一棵树,我们称之为”Render Tree“即”渲染树“。
总结一下,我们可以认为Flutter的UI系统包含三棵树:Widget树、Element树、渲染树。他们的依赖关系是:Element树根据Widget树生成,而渲染树又依赖于Element树,如图所示。
现在我们重点看一下Element
,Element
的生命周期如下:
-
Framework 调用
Widget.createElement
创建一个Element
实例,记为element
-
Framework 调用
element.mount(parentElement,newSlot)
,mount
方法中首先调用element
所对应Widget
的createRenderObject
方法创建与element
相关联的RenderObject
对象,然后调用element.attachRenderObject
方法将element.renderObject
添加到渲染树中插槽指定的位置(这一步不是必须的,一般发生在Element
树结构发生变化时才需要重新添加)。插入到渲染树后的element
就处于“active
”状态,处于“active
”状态后就可以显示在屏幕上了(可以隐藏)。 -
当有父
Widget
的配置数据改变时,同时其State.build
返回的Widget
结构与之前不同,此时就需要重新构建对应的Element
树。为了进行Element
复用,在Element
重新构建前会先尝试是否可以复用旧树上相同位置的element
,element
节点在更新前都会调用其对应Widget
的canUpdate
方法,如果返回true
,则复用旧Element
,旧的Element
会使用新Widget
配置数据更新,反之则会创建一个新的Element
。Widget.canUpdate
主要是判断newWidget
与oldWidget
的runtimeType
和key
是否同时相等,如果同时相等就返回true
,否则就会返回false
。根据这个原理,当我们需要强制更新一个Widget
时,可以通过指定不同的Key
来避免复用。 -
当有祖先
Element
决定要移除element
时(如Widget
树结构发生了变化,导致element
对应的Widget
被移除),这时该祖先Element
就会调用deactivateChild
方法来移除它,移除后element.renderObject
也会被从渲染树中移除,然后Framework会调用element.deactivate
方法,这时element
状态变为“inactive
”状态。 -
“
inactive
”态的element
将不会再显示到屏幕。为了避免在一次动画执行过程中反复创建、移除某个特定element
,“inactive
”态的element
在当前动画最后一帧结束前都会保留,如果在动画执行结束后它还未能重新变成“active
”状态,Framework就会调用其unmount
方法将其彻底移除,这时element
的状态为defunct
,它将永远不会再被插入到树中。 -
如果
element
要重新插入到Element
树的其他位置,如element
或element
的祖先拥有一个GlobalKey
(用于全局复用元素),那么Framework会先将element
从现有位置移除,然后再调用其activate
方法,并将其renderObject
重新attach
到渲染树。
总结:
- 一个Element对象将在被创建时初始化
initial
状态,并在通过mount
方法加入Element Tree后变为active
状态;当该节点对应的Widget失效后,其自身会通过deactivate
方法进入inactive
状态。如果在当前帧的Build过程中,有其他Element
节点通过key
复用了该节点,则会通过activate
方法使得该节点再次进入active
状态;如果当前帧结束后该节点仍不在Element Tree中,则会通过unmount
方法进行卸载,并进入defunct
状态,等待后续逻辑的销毁。
看完Element
的生命周期,可能有些人会有疑问,开发者会直接操作Element树吗?
其实对于开发者来说,大多数情况下只需要关注Widget
树就行,Flutter框架已经将对Widget树的操作映射到了Element
树上,这可以极大的降低复杂度,提高开发效率。
但是了解Element
对理解整个Flutter UI框架是至关重要的,Flutter正是通过Element
这个纽带将Widget
和RenderObject
关联起来,了解Element层不仅会帮助开发者对Flutter UI框架有个清晰的认识,而且也会提高自己的抽象能力和设计能力。另外在有些时候,我们必须得直接使用Element对象来完成一些操作,比如获取主题Theme数据。
BuildContext
我们已经知道,StatelessWidget
和StatefulWidget
的build
方法都会传一个BuildContext
对象:
Widget build(BuildContext context) {}
我们也知道,在很多时候我们都需要使用这个context
做一些事,比如:
Theme.of(context) // 获取主题
Navigator.push(context, route) // 入栈新路由
Localizations.of(context, type) // 获取Local
context.size // 获取上下文大小
context.findRenderObject() // 查找当前或最近的一个祖先RenderObject
那么BuildContext
到底是什么呢,查看其定义,发现其是一个抽象接口类:
abstract class BuildContext {
...
}
那这个context
对象对应的实现类到底是谁呢?我们顺藤摸瓜,发现build
调用是发生在StatelessWidget
和StatefulWidget
对应的StatelessElement
和StatefulElement
的build
方法中,例如在StatelessElement
中:
class StatelessElement extends ComponentElement {
...
Widget build() => widget.build(this);
...
}
同样在StatefulElement
中:
class StatefulElement extends ComponentElement {
...
Widget build() => state.build(this);
...
}
发现build
传递的参数是this
,很明显!这个BuildContext
就是StatelessElement
或StatefulElement
本身。但StatelessElement
和StatefulElement
本身并没有实现BuildContext
接口,继续跟踪代码,发现它们间接继承自Element
类,然后查看Element
类定义,发现Element
类果然实现了BuildContext
接口:
abstract class ComponentElement extends Element {...}
abstract class Element extends DiagnosticableTree implements BuildContext {...}
至此真相大白,BuildContext
就是widget
对应的Element
,所以我们可以通过context
在StatelessWidget
和StatefulWidget
的build
方法中直接访问Element
对象。我们获取主题数据的代码Theme.of(context)
内部正是调用了Element
的dependOnInheritedWidgetOfExactType()
方法。
总结:BuildContext
就是 Element
本尊,通过 BuildContext
的方法调用就是在操作 Element
,Widget
是外衣,而 Element
就是外衣下的裸体。
进阶
我们可以看到Element
是Flutter UI框架内部连接widget
和RenderObject
的纽带,大多数时候开发者只需要关注widget
层即可,但是widget
层有时候并不能完全屏蔽Element
细节,所以Framework在StatelessWidget
和StatefulWidget
中通过build
方法参数又将Element
对象也传递给了开发者,这样一来,开发者便可以在需要时直接操作Element
对象。
那么现在有两个问题:
1. 如果没有 widget 层,单靠 Element 层是否可以搭建起一个可用的UI框架?如果可以应该是什么样子?
2. Flutter UI 框架能不做成响应式吗?
对于问题 1,答案当然是肯定的,因为我们之前说过widget
树只是Element
树的映射,它只提供描述UI树的配置信息,Widget
就是外衣,一个人不穿衣服当然也可以比较羞耻地活着,但是穿上衣服他会活的更体面,即便不依赖Widget
我们也可以完全通过Element
来搭建一个UI框架。
下面举一个例子:
我们通过纯粹的Element
来模拟一个StatefulWidget
的功能,假设有一个页面,该页面有一个按钮,按钮的文本是一个9位数,点击一次按钮,则对9个数随机排一次序,代码如下:
class HomeView extends ComponentElement{
HomeView(Widget widget) : super(widget);
String text = "123456789";
Widget build() {
Color primary = Theme.of(this).primaryColor; //1
return GestureDetector(
child: Center(
child: TextButton(
child: Text(text, style: TextStyle(color: primary),),
onPressed: () {
var t = text.split("")..shuffle();
text = t.join();
markNeedsBuild(); //点击后将该Element标记为dirty,Element将会rebuild
},
),
),
);
}
}
-
上面
build
方法不接收参数,这一点和在StatelessWidget
和StatefulWidget
中build(BuildContext)
方法不同。代码中需要用到BuildContext
的地方直接用this
代替即可,如代码注释 1 处Theme.of(this)
参数直接传this
即可,因为当前对象本身就是Element
实例。 -
当
text
发生改变时,我们调用markNeedsBuild()
方法将当前Element
标记为dirty
即可,标记为dirty
的Element
会在下一帧中重建。实际上,State.setState()
在内部也是调用的markNeedsBuild()
方法。 -
上面代码中
build
方法返回的仍然是一个widget
,这是由于Flutter框架中已经有了widget
这一层,并且组件库都已经是以widget
的形式提供了,如果在Flutter框架中所有组件都像示例的HomeView
一样以Element
形式提供,那么就可以用纯Element
来构建UI了。HomeView
的build
方法返回值类型就可以是Element
了。
如果我们需要将上面代码在现有Flutter框架中跑起来,那么还是得提供一个“适配器”widget
将HomeView
结合到现有框架中,下面CustomHome
就相当于“适配器”:
class CustomHome extends Widget {
Element createElement() {
return HomeView(this);
}
}
现在就可以将CustomHome
添加到widget
树了,我们在一个新路由页创建它,最终效果如下图所示:
点击按钮则按钮文本会随机排序。
对于问题 2,答案当然也是肯定的,Flutter 引擎提供的 API 是原始且独立的,这个与操作系统提供的API类似,上层UI框架设计成什么样完全取决于设计者,完全可以将UI框架设计成 Android 风格或 iOS 风格,但这些事Google不会再去做。所以在理论上我们可以做,但是没必要,这是因为响应式的思想本身是很棒的,之所以提出这个问题,是因为做与不做是一回事,但知道能不能做是另一回事,这能反映出我们对知识的理解程度。
RenderObject
我们说过每个Element
都对应一个RenderObject
,我们可以通过Element.renderObject
来获取。并且我们也说过RenderObject
的主要职责是Layout和绘制,所有的RenderObject
会组成一棵渲染树Render Tree。下面将重点介绍一下RenderObject
的作用。
RenderObject
就是渲染树中的一个对象,它主要的作用是实现事件响应以及渲染管线中除过 build
的执行过程(build
过程由 element
实现),即包括:布局、绘制、层合成以及上屏。
RenderObject
关键类及其子类如图5-3所示,其每个子类都对应了一个RenderObjectWidget
类型的Widget
节点。
RenderView
是一个特殊的RenderObject
,是整个Render Tree的根节点。- 另外一个特殊的
RenderObject
是RenderAbstractViewport
,它是一个抽象类。RenderViewport
会实现其接口,并间接继承自RenderBox
。 RenderBox
和RenderSliver
是Flutter中最常见的RenderObject
,RenderBox
负责行、列等常规布局,而RenderSliver
负责列表内每个Item
的布局。
RenderObject
拥有一个parent
和一个parentData
属性,parent
指向渲染树中自己的父节点,而parentData
是一个预留变量,在父组件的布局过程,会确定其所有子组件布局信息(如位置信息,即相对于父组件的偏移),而这些布局信息需要在布局阶段保存起来,因为布局信息在后续的绘制阶段还需要被使用(用于确定组件的绘制位置),而parentData
属性的主要作用就是保存布局信息,比如在 Stack
布局中,RenderStack
就会将子元素的偏移数据存储在子元素的parentData
中(具体可以查看Positioned
实现)。
问题:既然有了RenderObject
,Flutter框架为什么还要专门提供RenderBox
和 RenderSliver
两个子类?
-
这是因为
RenderObject
类本身实现了一套基础的布局和绘制协议,但是却并没有定义子节点模型(如一个节点可以有几个子节点?), 它也没有定义坐标系统(如子节点定位是在笛卡尔坐标中还是极坐标?)和具体的布局协议(是通过宽高还是通过constraint和size?,或者是否由父节点在子节点布局之前或之后设置子节点的大小和位置等)。 -
为此,Flutter框架提供了一个
RenderBox
和一个RenderSliver
类,它们都是继承自RenderObject
,布局坐标系统采用笛卡尔坐标系,屏幕的(top, left)
是原点。而 Flutter 基于这两个类分别实现了基于RenderBox
的盒模型布局和基于Sliver
的按需加载模型。
启动流程(根节点构建流程)
Flutter Engine 是基于Dart运行环境,即 Dart Runtime,Dart Runtime 的启动关键流程如下:
其中,Dart Runtime 会首先创建和启动 DartVM
虚拟机,而 DartVM
启动后则会初始化一个DartIsolate
,然后启动它,在DartIsolate
启动流程的最后就会执行Dart应用程序的入口main
方法。也就是我们日常开发中 "lib/main.dart
"的main()
函数:
void main() => runApp(MyApp());
可以看main()
函数只调用了一个runApp()
方法,我们看看runApp()
方法中都做了什么:
void runApp(Widget app) {
final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized();
binding
..scheduleAttachRootWidget(binding.wrapWithDefaultView(app))
..scheduleWarmUpFrame();
}
这里参数app
是一个 widget
,它就是我们开发者传给Flutter框架的Widget,是 Flutter 应用启动后要展示的第一个组件,而WidgetsFlutterBinding
正是绑定widget
框架和Flutter 引擎的桥梁,定义如下:
class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
static WidgetsBinding ensureInitialized() {
if (WidgetsBinding._instance == null) {
WidgetsFlutterBinding();
}
return WidgetsBinding.instance;
}
}
先看一下 WidgetsFlutterBinding
的继承关系,我们发现WidgetsFlutterBinding
继承自BindingBase
并混入了很多Binding
类,所以其启动时将按照mixin的顺序依次触发这些类的构造函数。
GestureBinding
:负责手势的处理,提供了window.onPointerDataPacket
回调,绑定Framework手势子系统,是Framework事件模型与底层事件的绑定入口。ServicesBinding
:负责提供平台相关能力,提供了window.onPlatformMessage
回调, 用于绑定平台消息通道(message channel),主要处理原生和Flutter通信。SchedulerBinding
:负责渲染流程中各种回调的管理,提供了window.onBeginFrame
和window.onDrawFrame
回调,监听刷新事件,绑定Framework绘制调度子系统。PaintingBinding
:负责绘制相关的逻辑,绑定绘制库,主要用于处理图片缓存。SemanticsBinding
:负责提供无障碍能力,语义化层与Flutter engine的桥梁,主要是辅助功能的底层支持。RendererBinding
: 负责Render Tree的最终渲染,持有PipelineOwner
对象,提供了window.onMetricsChanged
、window.onTextScaleFactorChanged
等回调。它是渲染树与Flutter engine的桥梁。WidgetsBinding
:负责 Flutter 3 棵树的管理,持有BuilderOwner
对象,提供了window.onLocaleChanged
、onBuildScheduled
等回调。它是Flutter widget层与engine的桥梁。
在了解为什么要混入这些Binding
之前我们先介绍一下Window
,Window
是 Flutter Framework 连接宿主操作系统的接口。我们看一下 Window
类的部分定义:
class Window {
// 当前设备的DPI,即一个逻辑像素显示多少物理像素,数字越大,显示效果就越精细保真。
// DPI是设备屏幕的固件属性,如Nexus 6的屏幕DPI为3.5
double get devicePixelRatio => _devicePixelRatio;
// Flutter UI绘制区域的大小
Size get physicalSize => _physicalSize;
// 当前系统默认的语言Locale
Locale get locale;
// 当前系统字体缩放比例。
double get textScaleFactor => _textScaleFactor;
// 当绘制区域大小改变回调
VoidCallback get onMetricsChanged => _onMetricsChanged;
// Locale发生变化回调
VoidCallback get onLocaleChanged => _onLocaleChanged;
// 系统字体缩放变化回调
VoidCallback get onTextScaleFactorChanged => _onTextScaleFactorChanged;
// 绘制前回调,一般会受显示器的垂直同步信号VSync驱动,当屏幕刷新时就会被调用
FrameCallback get onBeginFrame => _onBeginFrame;
// 绘制回调
VoidCallback get onDrawFrame => _onDrawFrame;
// 点击或指针事件回调
PointerDataPacketCallback get onPointerDataPacket => _onPointerDataPacket;
// 调度Frame,该方法执行后,onBeginFrame和onDrawFrame将紧接着会在合适时机被调用,
// 此方法会直接调用Flutter engine的Window_scheduleFrame方法
void scheduleFrame() native 'Window_scheduleFrame';
// 更新应用在GPU上的渲染,此方法会直接调用Flutter engine的Window_render方法
void render(Scene scene) native 'Window_render';
// 发送平台消息
void sendPlatformMessage(String name, ByteData data, PlatformMessageResponseCallback callback) ;
// 平台通道消息处理回调
PlatformMessageCallback get onPlatformMessage => _onPlatformMessage;
... //其他属性及回调
}
可以看到Window
类包含了当前设备和系统的一些信息以及Flutter Engine的一些回调。
现在我们再回来看看WidgetsFlutterBinding
混入的各种Binding
。通过查看这些 Binding
的源码,我们可以发现这些Binding
中基本都是监听并处理Window
对象的一些事件,然后将这些事件按照Framework的模型包装、抽象然后分发。可以看到WidgetsFlutterBinding
正是粘连 Flutter Engine 与上层Framework 的“胶水”。WidgetsFlutterBinding
的本质就是一个WidgetsBinding
,自身并没有特殊逻辑,所以通过混入这些binding
类获得了额外的能力。
而WidgetsFlutterBinding.ensureInitialized()
方法中主要负责初始化了一个WidgetsBinding
的全局单例,并返回WidgetsBinding
单例对象,除此外,没有做任何其他事情。这也正说明了它只是一个站在众人肩膀上的粘合剂。
再回到runApp
方法中,获得WidgetsBinding
单例对象后,紧接着会调用WidgetsBinding
的scheduleAttachRootWidget
方法而在其中又调用了attachRootWidget
方法,代码如下:
void scheduleAttachRootWidget(Widget rootWidget) {
Timer.run(() { attachRootWidget(rootWidget); }); // 注意,不是立即执行
}
void attachRootWidget(Widget rootWidget) {
final bool isBootstrapFrame = rootElement == null;
_readyToProduceFrames = true; // 开始生成 Element Tree
_rootElement = RenderObjectToWidgetAdapter<RenderBox>(
container: renderView, // Render Tree的根节点
debugShortDescription: '[root]',
child: rootWidget, // 开发者通过runApp传入Widget Tree的根节点
).attachToRenderTree(buildOwner!, rootElement as RenderObjectToWidgetElement<RenderBox>?);
if (isBootstrapFrame) {
SchedulerBinding.instance.ensureVisualUpdate(); // 请求渲染
}
}
以上逻辑正是驱动Element Tree
和Render Tree
进行创建的入口,需要注意的是,attachRootWidget
是通过 Timer.run
启动的,这是为了保证所有逻辑都处于消息循环的管理中。
attachRootWidget
方法主要负责将根Widget
添加到RenderView
上,注意,代码中有renderView
和renderViewElement
两个变量,renderView
是一个RenderObject
,它是渲染树的根,而renderViewElement
是renderView
对应的Element
对象,可见该方法主要完成了根widget
到根 RenderObject
再到根Element
的整个关联过程。
attachToRenderTree
方法将驱动Element Tree
的构建,并返回其根节点, 源码实现如下:
RenderObjectToWidgetElement<T> attachToRenderTree(
BuildOwner owner, [ RenderObjectToWidgetElement<T>? element ]) {
if (element == null) { // 首帧构建,element参数为空
owner.lockState(() {
element = createElement(); // 创建Widget对应的Element
element!.assignOwner(owner); // 绑定BuildOwner
});
owner.buildScope(element!, () { // 开始子节点的解析与挂载
element!.mount(null, null);
});
} else { // 如热重载等场景
element._newWidget = this;
element.markNeedsBuild();
}
return element!;
}
该方法负责创建根element
,即RenderObjectToWidgetElement
,并且将element
与widget
进行关联,即创建出 widget
树对应的element
树。如果 element
已经创建过了,则将根element
中关联的widget
设为新的,由此可以看出element
只会创建一次,后面会进行复用。由于首帧的element
参数为null
,因此首先通过createElement
方法完成创建,然后和BuildOwner
的实例绑定,那么BuildOwner
是什么呢?其实它就是widget
framework的管理类,它跟踪哪些 widget
需要重新构建。该对象将在后面驱动Element Tree
的更新。
在完成3棵树的构建之后,会触发attachRootWidget
中的ensureVisualUpdate
的逻辑:
void ensureVisualUpdate() {
switch (schedulerPhase) {
case SchedulerPhase.idle: // 闲置阶段,没有需要渲染的帧
// 计算注册到本次帧渲染的一次性高优先级回调,通常是与动画相关的计算
case SchedulerPhase.postFrameCallbacks:
scheduleFrame();
return;
case SchedulerPhase.transientCallbacks: // 处理Dart中的微任务
// 计算待渲染帧的数据,包括Build、Layout、Paint等流程,这部分内容后面将详细介绍
case SchedulerPhase.midFrameMicrotasks:
// 帧渲染的逻辑结束,处理注册到本次帧渲染的一次性低优先级回调
case SchedulerPhase.persistentCallbacks:
return;
}
}
以上逻辑将根据当前所处的阶段判断是否需要发起一次帧渲染,每个阶段的状态转换如图5-8所示。
在图5-8中,首先,如果没有外部(如setState
方法)和内部(如动画心跳、图片加载完成的监听器)的驱动,Framework将默认处于idle
状态。如果有新的帧数据请求渲染,Framework将在Engine的驱动下,在handleBeginFrame
方法中进入transientCallbacks
状态,主要是处理高优先级的一次性回调,比如动画计算。完成以上逻辑后,Framework会将自身状态更新为midFrameMicrotasks
,具体的微任务处理由Engine驱动。其次,Engine会调用handleDrawFrame
方法,Framework在此时将状态更新为persistentCallbacks
,表示自身将处理每帧必须执行的逻辑,主要是与渲染管道相关的内容。完成Framework中与渲染管道相关的逻辑后,Framework会将自身状态更新为postFrameCallbacks
,并处理低优先级的一次性回调(通常是由开发者或者上层逻辑注册)。最后,Framework将状态重置为idle
。idle
是Framework的最终状态,只有在需要帧渲染时才会开始一次状态循环。
scheduleFrame
方法的逻辑如下所示,它将通过platformDispatcher.scheduleFrame
接口发起请求,要求在下一个Vsync
信号到达的时候进行渲染。
void scheduleFrame() {
if (_hasScheduledFrame || !framesEnabled) return;
ensureFrameCallbacksRegistered();
platformDispatcher.scheduleFrame();
_hasScheduledFrame = true;
}
回到runApp
的实现中,在组件树在构建(build)完毕后,当调用完attachRootWidget
后,最后一步会调用 WidgetsFlutterBinding
实例的 scheduleWarmUpFrame()
方法,该方法的实现在SchedulerBinding
中,它被调用后会立即进行一次绘制,在此次绘制结束前,该方法会锁定事件分发,也就是说在本次绘制结束完成之前 Flutter 将不会响应各种事件,这可以保证在绘制过程中不会再触发新的重绘。
渲染管线
前面分析了runApp
方法在执行完ensureInitialized
方法所触发的初始化流程后,将触发scheduleAttachRootWidget
和scheduleWarmUpFrame
两个方法,前者负责Render Tree的生成,后者负责首帧渲染的触发。
1. Frame
一次绘制过程,我们称其为一帧(frame)。我们之前说的 Flutter 可以实现60fps(Frame Per-Second)就是指一秒钟最多可以触发 60 次重绘,FPS 值越大,界面就越流畅。这里需要说明的是 Flutter中 的 frame 概念并不等同于屏幕刷新帧(frame),因为Flutter UI 框架的 frame 并不是每次屏幕刷新都会触发,这是因为,如果 UI 在一段时间不变,那么每次屏幕刷新都重新走一遍渲染流程是不必要的,因此,Flutter 在第一帧渲染结束后会采取一种主动请求 frame 的方式来实现只有当UI可能会改变时才会重新走渲染流程。
- Flutter 在 window 上注册一个
onBeginFrame
和一个onDrawFrame
回调,在onDrawFrame
回调中最终会调用drawFrame
。 - 当我们调用
window.scheduleFrame()
方法之后,Flutter引擎会在合适的时机(可以认为是在屏幕下一次刷新之前,具体取决于Flutter引擎的实现)来调用onBeginFrame
和onDrawFrame
。
可见,只有主动调用scheduleFrame()
,才会执行 drawFrame
。所以,我们在Flutter 中的提到 frame
时,如无特别说明,则是和 drawFrame()
的调用对应,而不是和屏幕的刷新频率对应。
2. Flutter 调度过程 SchedulerPhase
Flutter 应用执行过程简单来讲分为 idle
和 frame
两种状态,idle
状态代表没有 frame
处理,如果应用状态改变需要刷新 UI,则需要通过scheduleFrame()
去请求新的 frame
,当 frame
到来时,就进入了frame
状态,整个Flutter应用生命周期就是在 idle
和 frame
两种状态间切换。
frame 处理流程
当有新的 frame
到来时,具体处理过程就是依次执行四个任务队列:transientCallbacks、midFrameMicrotasks、persistentCallbacks、postFrameCallbacks
,当四个任务队列执行完毕后当前 frame
结束。综上,Flutter 将整个生命周期分为五种状态,通过 SchedulerPhase
枚举类来表示它们:
enum SchedulerPhase {
/// 空闲状态,并没有 frame 在处理。这种状态代表页面未发生变化,并不需要重新渲染。
/// 如果页面发生变化,需要调用`scheduleFrame()`来请求 frame。
/// 注意,空闲状态只是指没有 frame 在处理,通常微任务、定时器回调或者用户事件回调都
/// 可能被执行,比如监听了tap事件,用户点击后我们 onTap 回调就是在idle阶段被执行的。
idle,
/// 执行”临时“回调任务,”临时“回调任务只能被执行一次,执行后会被移出”临时“任务队列。
/// 典型的代表就是动画回调会在该阶段执行。
transientCallbacks,
/// 在执行临时任务时可能会产生一些新的微任务,比如在执行第一个临时任务时创建了一个
/// Future,且这个 Future 在所有临时任务执行完毕前就已经 resolve 了,这中情况
/// Future 的回调将在[midFrameMicrotasks]阶段执行
midFrameMicrotasks,
/// 执行一些持久的任务(每一个frame都要执行的任务),比如渲染管线(构建、布局、绘制)
/// 就是在该任务队列中执行的.
persistentCallbacks,
/// 在当前 frame 在结束之前将会执行 postFrameCallbacks,通常进行一些清理工作和
/// 请求新的 frame。
postFrameCallbacks,
}
3. 渲染管线(rendering pipeline)
当新的 frame
到来时,调用到 WidgetsBinding
的 drawFrame()
方法,我们来看看它的实现:
void drawFrame() {
...//省略无关代码
try {
buildOwner.buildScope(renderViewElement); // 先执行构建
super.drawFrame(); //然后调用父类的 drawFrame 方法
}
}
实际上关键的代码就两行:先重新构建(build
),然后再调用父类的 drawFrame
方法,我们将父类的 drawFrame
方法展开后:
void drawFrame() {
buildOwner!.buildScope(renderViewElement!); // 1.重新构建widget树
//下面是 展开 super.drawFrame() 方法
pipelineOwner.flushLayout(); // 2.更新布局
pipelineOwner.flushCompositingBits(); //3.更新“层合成”信息
pipelineOwner.flushPaint(); // 4.重绘
if (sendFramesToEngine) {
renderView.compositeFrame(); // 5. 上屏,会将绘制出的bit数据发送给GPU
...
}
}
可以看到主要做了5件事:
- 重新构建widget树。
- 更新布局。
- 更新“层合成”信息。
- 重绘。
- 上屏:将绘制的产物显示在屏幕上。
我们称上面的5步为 rendering pipeline
,中文翻译为 “渲染流水线” 或 “渲染管线”。
下面我们以 setState
的执行更新的流程为例先对整个更新流程有一个大概的了解。
setState 执行流程
当 setState
调用后:
- 首先调用当前
element
的markNeedsBuild
方法,将当前element
的_dirty
标记为true
。 - 接着调用
scheduleBuildFor
,将当前element
添加到BuildOwner
的_dirtyElements
列表中。 - 同时会请求一个新的
frame
,随后会绘制新的frame
:onBuildScheduled->ensureVisualUpdate->scheduleFrame()
。
下面是 setState
执行的大概流程图:
其中 updateChild()
的逻辑如下:
其中 onBuildScheduled
方法在启动阶段完成初始化,它最终将调用ensureVisualUpdate
,,它将触发 Vsync 信号的监听。当新的 Vsync 信号到达后将触发 buildScope
方法,这会进行重建子树,同时会执行渲染管线流程:
void drawFrame() {
buildOwner!.buildScope(renderViewElement!); //重新构建widget树
pipelineOwner.flushLayout(); // 更新布局
pipelineOwner.flushCompositingBits(); //更新合成信息
pipelineOwner.flushPaint(); // 更新绘制
if (sendFramesToEngine) {
renderView.compositeFrame(); // 上屏,会将绘制出的bit数据发送给GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
_firstFrameSent = true;
}
}
-
重新构建
widget
树:如果dirtyElements
列表不为空,则遍历该列表,调用每一个element
的rebuild
方法重新构建新的widget
(树),由于新的widget
(树)使用新的状态构建,所以可能导致widget
布局信息(占用的空间和位置)发生变化,如果发生变化,则会调用其renderObject
的markNeedsLayout
方法,该方法会从当前节点向父级查找,直到找到一个relayoutBoundary
的节点,然后会将它添加到一个全局的nodesNeedingLayout
列表中;如果直到根节点也没有找到relayoutBoundary
,则将根节点添加到nodesNeedingLayout
列表中。 -
更新布局:遍历
nodesNeedingLayout
数组,对每一个renderObject
重新布局(调用其layout
方法),确定新的大小和偏移。layout
方法中会调用markNeedsPaint()
,该方法和markNeedsLayout
方法功能类似,也会从当前节点向父级查找,直到找到一个isRepaintBoundary
属性为true
的父节点,然后将它添加到一个全局的nodesNeedingPaint
列表中;由于根节点(RenderView
)的isRepaintBoundary
为true
,所以必会找到一个。查找过程结束后会调用buildOwner.requestVisualUpdate
方法,该方法最终会调用scheduleFrame()
,该方法中会先判断是否已经请求过新的frame
,如果没有则请求一个新的frame
。 -
更新合成信息:先忽略。
-
更新绘制:遍历
nodesNeedingPaint
列表,调用每一个节点的paint
方法进行重绘,绘制过程会生成Layer
。需要说明一下,flutter中绘制结果是保存在Layer
中的,也就是说只要Layer
不释放,那么绘制的结果就会被缓存,因此,Layer
可以跨frame
来缓存绘制结果,避免不必要的重绘开销。Flutter框架绘制过程中,遇到isRepaintBoundary
为true
的节点时,才会生成一个新的Layer
。可见Layer
和renderObject
不是一一对应关系,父子节点可以共享,这个我们会在随后的一个试验中来验证。当然,如果是自定义组件,我们可以在renderObject中手动添加任意多个 Layer,这通常用于只需一次绘制而随后不会发生变化的绘制元素的缓存场景,这个随后我们也会通过一个例子来演示。 -
上屏:绘制完成后,我们得到的是一棵
Layer
树,最后我们需要将Layer
树中的绘制信息在屏幕上显示。我们知道Flutter是自实现的渲染引擎,因此,我们需要将绘制信息提交给Flutter engine,而renderView.compositeFrame
正是完成了这个使命。
以上,便是setState
调用到UI更新的大概更新过程,实际的流程会更复杂一些,比如在build
过程中是不允许再调用setState
的,框架需要做一些检查。又比如在frame
中会涉及到动画的的调度、在上屏时会将所有的Layer
添加到场景(Scene)对象后,再渲染Scene。
setState 执行时机问题
setState
会触发 build
,而 build
是在执行 persistentCallbacks
阶段执行的,因此只要不是在该阶段执行 setState
就绝对安全,但是这样的粒度太粗,比如在transientCallbacks
和 midFrameMicrotasks
阶段,如果应用状态发生变化,最好的方式是只将组件标记为 dirty
,而不用再去请求新的 frame
,因为当前frame
还没有执行到 persistentCallbacks
,因此后面执行到后就会在当前帧渲染管线中刷新UI。因此,setState
在标记完 dirty
后会先判断一下调度状态,如果是 idle
或 执行 postFrameCallbacks
阶段才会去请求新的 frame
:
void ensureVisualUpdate() {
switch (schedulerPhase) {
case SchedulerPhase.idle:
case SchedulerPhase.postFrameCallbacks:
scheduleFrame(); // 请求新的frame
return;
case SchedulerPhase.transientCallbacks:
case SchedulerPhase.midFrameMicrotasks:
case SchedulerPhase.persistentCallbacks: // 注意这一行
return;
}
}
上面的代码在大多数情况下是没有问题的,但是如果我们在 build
阶段又调用 setState
的话还是会有问题,因为如果我们在 build
阶段又调用 setState
的话就又会导致 build
…这样将导致循环调用,因此 flutter 框架发现在 build
阶段调用 setState
的话就会报错,如:
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, c) {
// build 阶段不能调用 setState, 会报错
setState(() {
++index;
});
return Text('xx');
},
);
}
运行后会报错,控制台会打印:
==== Exception caught by widgets library ====
The following assertion was thrown building LayoutBuilder:
setState() or markNeedsBuild() called during build.
需要注意,如果我们直接在 build
中调用setState
,代码如下:
Widget build(BuildContext context) {
setState(() {
++index;
});
return Text('$index');
}
运行后是不会报错的,原因是在执行 build
时当前组件的 dirty
状态(对应的element
中)为 true
,只有 build
执行完后才会被置为 false
。而 setState
执行的时候会会先判断当前 dirty
值,如果为 true
则会直接返回,因此就不会报错。
上面我们只讨论了在 build
阶段调用 setState
会导致错误,实际上在整个构建、布局和绘制阶段都不能同步调用 setState
,这是因为,在这些阶段调用 setState
都有可能请求新的 frame
,都可能会导致循环调用,因此如果要在这些阶段更新应用状态时,都不能直接调用 setState
。
安全更新
现在我们知道在 build
阶段不能调用 setState
了,实际上在组件的布局阶段和绘制阶段也都不能直接再同步请求重新布局或重绘,道理是相同的,那在这些阶段正确的更新方式是什么呢,我们以 setState
为例,可以通过如下方式:
// 在build、布局、绘制阶段安全更新
void update(VoidCallback fn) {
SchedulerBinding.instance.addPostFrameCallback((_) {
setState(fn);
});
}
注意,update
函数只应该在 frame
执行 persistentCallbacks
时执行,其他阶段直接调用 setState
即可。因为 idle
状态会是一个特例,如果 在idle
状态调用 update
的话,需要手动调用 scheduleFrame()
请求新的 frame
,否则 postFrameCallbacks
在下一个frame
(其他组件请求的 frame
)到来之前不会被执行,因此我们可以将 update
修改一下:
void update(VoidCallback fn) {
final schedulerPhase = SchedulerBinding.instance.schedulerPhase;
if (schedulerPhase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
setState(fn);
});
} else {
setState(fn);
}
}
至此,我们封装了一个可以安全更新状态的 update
函数。
现在我们回想一下,“自定义组件:CustomCheckbox” 一节中,为了执行动画,我们在绘制完成之后通过如下代码请求重绘:
SchedulerBinding.instance.addPostFrameCallback((_) {
...
markNeedsPaint();
});
我们并没有直接调用 markNeedsPaint()
,而原因正如上面所述。
总结
需要说明的是 Build 过程和 Layout 过程是可以交替执行的。
参考:
- 《Flutter实战·第二版》
- 《Flutter内核源码剖析》