Flutter 内部是如何工作的?Widgets
、Elements
、BuildContext
到底是什么东西?为什么 Flutter 可以运行那么快?为什么有时候运行的效果并不符合我们的预期?什么是所谓视图树?——本文将一一为你解答。
作者 | Didier Boelens
译者 | 罗昭成,责编 | 郭芮
以下为译文:
Flutter 内部是如何工作的?Widgets
、Elements
、BuildContext
、RenderObject
这些都是些什么东西?
简介
去年,我刚开始使用 Flutter 开发应用程序时,在互联网上,只能找到极少量Flutter相关文档。虽然有一些文章在谈论 Flutter,但基本没有文章写 Flutter 内部的工作原理。
Widgets
、Elements
、BuildContext
到底是什么东西?为什么 Flutter 可以运行那么快?为什么有时候运行的效果并不符合我们的预期?什么是所谓视图树?
在Flutter开发一个应用程序时,有 95% 的需求,都只需要处理 Widgets
,用它来展示 UI 并处理屏幕交互。但你是否考虑过,整个系统是如何工作的,是如何知道要更新哪些 UI 呢?
第一部分:背景
这一部份内容包含一些关键概念,了解他们,会有助于理解后面的内容。
硬件
让我们从最基础的东西开始说起。
当你在使用你的设备的时候,或者说你在使用某个应用程序的时候,你只会看到一块屏幕。
事实上,在屏幕上,你看到了一系列的像素点,这些像素点,共同组成了一个二维的图像。当你触摸屏幕的时候,屏幕也只识别你手指在屏幕中的位置。
神奇的是,我们的应用程序可以触发屏幕显示的图像更新。在以下情况下,应用程序可以触发屏幕显示图像更新:
-
屏幕事件 (点击屏幕 )
-
网络事件 (与服务器通信)
-
时间事件 (动画)
-
其它传感器事件
将图片渲染到屏幕上是由显示屏硬件来保证,这些显示屏都是按固定的间隔时间来刷新屏幕,通常是1秒钟刷新60次。这个刷新的频率,我们通常叫作 刷新率,用HZ表示。
设备从 GPU 接收到要在屏幕上显示的数据,渲染显示在屏幕上(注:GPU 是一种专用的电子电路,经过优化,可以从 polygons 和 textures 获取数据,并快速生成图像)。我们将 GPU 在每秒中生成并发送给设备用于显示的图像的次数叫做 帧率, 使用FPS来做为单位进行计量。
看到这里,你也许会问,在文章的开篇,我们写到二维图像与硬件屏幕的渲染,这些又和 Flutter 的 Widgets 有什么关系呢?
答案很简单,因为 Flutter 的一个核心功能就是合成二维图像并处理交互。我认为,从这个角度来解读,可以更好的理解 Flutter 的内部工作原理。
当然,还有一个原因,不管你信或者不信,Flutter 中几乎所有的事情都是由刷新屏幕的需求来驱动的,我们需要在适当的时候来快速刷新我们的屏幕。
接口设计
不管什么时候,只要你加入Flutter开发的行列中,都会看到下面这张 Flutter 的架构图。
我们在开发 Flutter 的应用程序的时候,使用 Dart 语言,我们面象的 API 都是 Flutter框架(绿色部分) 这一层提供的。
Flutter 框架通过 Window这个抽象层与 Flutter引擎(蓝色部分)进行通信,Window中抽象出来了一系列的 API,实现了与硬件通信的接口。
当然,在下面的这些情况中,Flutter引擎也可以通过 Window来通知 Flutter 框架层来进行事件处理。
-
设备级别的属性更改 (设备方向改变,设置修改,内存问题,APP状态修改等)
-
屏幕级别的更改(手势)
-
平台渠道发送的数据
-
在 Flutter 引擎层空闲下来,可以渲染新的帧的时候,会发送通知给 Flutter 框架层。
Flutter Engine 渲染驱动 Flutter 框架
这一部分内容很难以理解,但是这就是真实的逻辑。
除了以下几种情况, Flutter 框架的代码执行都是由 Flutter 引擎触发的。
-
手势 (屏幕上的事件)
-
平台消息(如 GPS)
-
硬件消息(如旋转屏幕, 应用压后台,内存不足等)
-
异步消息( Future API 或者 HTTP 响应)
注:
一般情况下,如果Flutter渲染引擎没有发出通知, Flutter 框架是不能更新任何UI的。
有些时候,在没有 Flutter 渲染引擎通知的情况下,也可以让 Flutter 框架更新UI,但是并不建议这么做。
你或许会问我,执行手势相关的逻辑,会让 UI 发生变化;使用一个异步任务,或者动画,也会让 UI 发生改变,那它们又是如何工作的呢?
如果你想更新UI, 或者说你想在后台执行代码逻辑并更新 UI,你需要告诉 Flutter 引擎,这里有一些更改需要被渲染到屏幕上。通常情况下,在屏幕下一次刷新的时候, Flutter引擎会通知 Flutter框架,让它来提供新场景的图像来进行渲染显示。
因此,Flutter 引擎是如何基于渲染编排整个应用程序的行为?
从下面的动画中,我们可以了解到整个内部运行机制:
整个动画过程解释如下:
-
像手势、http 网络请求和异步事件,它们都会触发一个异步任务,当它们引起 UI 的更新。它们会发送一个消息(Schedule Frame)给 Flutter引擎,告诉 Flutter引擎,有新的UI需要被渲染。
-
当 Flutter引擎准备好,可以更新UI的时候,它会发送 Begin Frame通知到Flutter框架。
-
Flutter 框架运行着的异步任务,如动画,它们会拦截掉 Begin Frame通知。
-
这些异步任务会根据自身状态进行判断是否需要继续发送请求给 Flutter 引擎,用来触发后续的UI渲染(例:当一个动画没有完成的时候,为了让动画可以继续执行,它会发送一个通知到 Flutter 引擎,然后会等待接收另一个 Begin Frame的通知)。
-
紧接着,Flutter 引擎会发出一个 Draw Frame的通知到 Flutter 框架层。
-
Flutter 框架会拦截 Draw Frame通知,并根据任务进行布局调整和UI大小计算。
-
完成这些任务后,它将继续执行与更新布局有关的绘画任务。
-
如果有什么要画在屏幕上,它会发送一个全新的场景数据到 Flutter 引擎,让Flutter引擎来更新到屏幕上。
-
最后,Flutter 框架执行完所有的任务并且在屏幕中渲染完成。
-
紧接着会继续一遍又一遍的执行上述流程。
RenderView 和 RenderObject
在讨论与事件流相关的细节之前,先来看看视图树。
如之前所说, 所有东西最后都会转换成像素显示在屏幕中, Flutter 框将我们用于开发程序使用的 Widgets转化成可视部分,显示在屏幕上。
在 Flutter 中,用来与渲染在屏幕上的可见视图一一对应的对象,我们称作 RenderObject
,它被用来表示:
-
定义屏幕中的区域,包括 大小,位置, 几何结构。也可称其为"渲染内容"。
-
识别可能受到手势影响的屏幕区域。
一堆 RenderObject
共同组成了一棵树,称之为视图树。在视图树的最上面,也就是其跟节点,就是RenderView。RenderView
代表了整个输出的视图树,它也是一种特殊的Renderobject
, 如图所示:
在文章的后面会讲解 Widgets
与RenderObjects
之前的关系。但在这之前,我们需要更深入的了解RenderObjects
。
Bindings的初始化
当 Flutter 应用程序启动的时候,系统会执行 main
方法,它会调用runApp(Widget app)
。
在调用runApp这个方法的时候,Flutter 会初始化 Flutter 框架与Flutter 引擎之间的接口,它被称作是bindings。
Bindings简介
Bindings是建立Flutter 框架与Flutter 引擎之间通信的桥梁,Flutter的这两个部分只能通过它传递数据(其中RenderView 是个例外,在后面会讲到)。
每个bindings负责处理一组特定的任务、操作或者事件。本文中,作者按其作用域进行了重新分组。
到目前为止, Flutter 框架提供8个bindings,本文中,只讨论以下四个:
-
SchedulerBinding
-
GestureBinding
-
RendererBinding
-
WidgetsBinding
为了保证完整性,我列出剩下四个 bindings:
-
ServicesBinding:处理不同平台发过来的消息
-
PaintingBinding:处理图片缓存
-
SemanticsBinding:保留到以后实现所有与语义相关的内容
-
TestWidgetsFlutterBinding:组件测试使用的
当然,我也注意到了 WidgetsFlutterBinding,但这个不是真正的bindings, 而是一种bindings初始化工具。
有关 bindings与 Flutter 引擎的交互逻辑,见下图所示:
下面,我们分别来看一下这几个 bindings。
SchedulerBinding
它有两个主要功能:
-
第一个是告诉 Flutter 引擎:“我现在已经准备好了,在你不忙的时候,把我唤醒,告诉我要渲染的内容,我会开始工作。”
-
第二个是监听并响应一些事件,如唤醒事件。
当SchedulerBinding接受到唤醒事件的时候,要做些什么呢?
-
需要 Ticker 来控制的时候
举个例子,假设您有一个动画,并且已经开始执行了。这个动画是由Ticker进行控制的,它需要以固定时间间隔触发回调。要让这样的回调运行,我们需要告诉Flutter 引擎在下次刷新时唤醒我们(发送Begin Frame),触发回调,执行动画任务。在该动画任务结束时,动画还需要继续,它将再次调用SchedulerBinding来调度另一帧。
-
更改布局
当你响应导致视觉变化的事件(例如,更新屏幕的一部分的颜色,滚动,向屏幕中添加/从屏幕中删除某些内容)时,我们需要采取必要的步骤来保证它可以正常的显示在屏幕上。在这种情况下,Flutter框架将调用SchedulerBinding来告诉Flutter Engine去调度另一帧。
GestureBinding
这个 binding处理手势事件,并与 Flutter 引擎进行通信。它负责接受与手指有关的数据,并确定屏幕的哪一部分受到手势的影响。然后,它会通知这些部分,来响应事件。
RendererBinding
它是 Flutter 引擎与视图树之前的桥梁,它有两个不同的功能:
-
第一个是监听Flutter引擎发出的消息,当设备设置发生更改,它会告知用户受到影响的视觉效果/语义。
-
第二个是为 Flutter 引擎提供要显示在屏幕上的数据。
为了提供要在屏幕上呈现的修改,它负责驱动PipelineOwner并初始化RenderView。
PipelineOwner是一种协调器,它知道哪个RenderObject需要做一些与布局有关的事情并协调这些动作。
WidgetsBinding
它用于监听用户设置的更改,如语言的修改。不仅如此, WidgetsBinding 否是 Widgets 与 Flutter 引擎之间通信的桥梁,有两个主要的功能:
-
第一个是负责处理Widgets结构变更的过程;
-
第二个是触发渲染事件。
一些小组件的结构更改是 BuildOwner 来完成的,它跟踪需要重建的小部件,并处理应用于整个小部件结构的其他任务。
第二部分:Widgets 转换成像素
基础的内部原理讲解完成,下面我们来看看 Widgets。
在所有有关 Flutter 的文档中,你能看到这样子的描述:在Fluter中,所有的对象都是 Widgets。这样子说虽然没错,但要说得更精确一些,我觉得应该是:
从开发人员的角度来看,在布局和交互方面,与用户界面相关的所有内容均通过Widgets完成。
为啥要如此精确?Widget允许开发人员根据尺寸,内容,布局和交互性来定义屏幕的一部分。不仅这些,还有更多的东西也是通过它来定义。那么,什么是Widgets呢?
不变的属性
在读 Flutter 的源码的时候,你可以在 Widget 类中看到如下定义:
1@immutable
2abstract class Widget extends DiagnosticableTree {
3 const Widget({ this.key });
4
5 final Key key;
6
7 ...
8}
这是什么意思?
@immutable这个注解告诉我们,在 Widget 类中定义的所有变量都是 FINAL 的。换句话说,它们只能被定义一次。因此,一但初始化完成,这个Widget的内部变量将不能被修改。
Widgets 结构
当你在使用用Flutter的时候,你写一个页面,会用到 Widget,像下面的代码中那样,定义出要显示的UI:
1Widget build(BuildContext context){
2 return SafeArea(
3 child: Scaffold(
4 appBar: AppBar(
5 title: Text('My title'),
6 ),
7 body: Container(
8 child: Center(
9 child: Text('Centered Text'),
10 ),
11 ),
12 ),
13 );
14}
上述例子中,一共使用了7个 Widgets,它们组成了一个树状结构。一个非常简单的结构,根据代码,可以画出如下结构图:
正如你看到的,他像一个树, SafeArea是这个树的根节点。
复杂的 Widget 结构
正如你知道的那样, Widget 可以将很多其它的Widget 聚合在一起。举个例子,我可以使用如下代码替换上面的代码:
1Widget build(BuildContext context){
2 return MyOwnWidget;
3}
我们假设 MyOwnWidget
会自己去渲染 SafeArea, Scaffold 等 Widget。这个例子,要表达的意思是:
Widget 可能是一个页子节点,也可能是一颗树。
元素
为什么我会提到这个,正如我们将在后面看到的那样,为了能够生成可以在设备上渲染的图像的像素,Flutter需要详细了解组成屏幕的所有Widget,并确定所有部分, 按要求生成 Widget 。
为了说明这一点,你可以想象一下俄罗斯套娃,最开始的时候,你只能看到一个玩偶,但是这个玩偶中,包含了另一个,然后依次包含另一个,以此内推。
Flutter 生成所有的 Widgets时,就像获得所有的俄罗斯套娃一样。
下图展示了如何显示 Widget 的所有部分,图中,黄色部分表示你在代码中写到的部分,你可以在不同的组件中使用:
注:在这里我们使用了 "Widget 树",这只是为了更好的理解逻辑,在 Flutter中并没有这个概念。
现在我们来介绍前面提到的元素。
每个小部件都对应一个元素。元素彼此链接并形成一棵树。因此,元素是树中某个节点的引用。
首先,将元素作为一个节点,它有父结点,也有子结点。然后通过父子关系将它们链接在一起,就可以得到一个树形结构。
如图中看到的, 元素可以对应一个 Widget ,也可以对应一个 RenderObject。
总结一下:
-
没有 Widgets 树,但是有元素树;
-
Widgets 创建 Elements;
-
Element 指向创建它的 Widget;
-
Elements 使用父子关系进行关联;
-
Elements 有一个或多个子节点;
-
Elements 也可以指向一个 RenderObject。
Elements 定义了视图部分的链接关系。
为了可以更好的表达 element的概念,让我们来看看下图:
图中所示, 元素树连接了 Widgets 和 RenderObjects。
但是,为什么 Widget 会创建 Element?
三种 Widgets
在 Flutter , Widgets 可以拆分成三类(仅仅是我个人为了组织它们,而进行的分类),如下:
-
代理类
这类 Widget 主要作用是用来保存一些数据信息的,并且做为树结构的根结点。点型的例子是 InheritedWidget
和LayoutId
这些 Widgets 不会展现出任何的用户页面,但是它们会用来为其它的Widgets提供数据。
-
渲染类
这类 Widget 直接或间接的用于屏幕布局:
-
大小尺寸
-
UI位置
-
布局/渲染方式
典型例子:Row
,Column
,Stack
、Padding
、Align
、Opacity
、RawImage
等。
-
组件类
剩下的这一类 Widget 不能直接用于大小、位置、布局等设置,它们只是用来展示最终的数据信息。这些 Widget 通常被称之为组件。
典型例子:RaisedButton
,Scaffold
,Text
,GestureDetector
,Container。
Widgets 分类
为什么拆分显得如此重要?因为根据 Widget 的分类,会关联不同的 Element。
Element 分类
下图中展示了不同的 Element 分类:
内部Element 分类
Element 主要分为两大类:
-
组件,此分类不直接用于任何视觉渲染的部分。
-
渲染,此分类是直接用于屏幕渲染。
到目前为止,出现了很多的概念,它们是如何关联在一起?
Widgets 和 Elements 是如何在一起工作的?
在Flutter中,整个系统都依赖 Widget / RenderObject 的状态。
更新 element 的状态的两种不同方式:
-
使用 setState方法,这个方法可以用于所有的StatefulElement(注意,我这里面说的不是StatefulWidget)。
-
使用通知,基于 ProxyElement来实现状态更新(如:InheritedWidget)。
Elements 的状态变更是需要Elements的引用放入了脏元素列表中。使RenderObject无效意味着没有任何更改应用于元素的结构,但是发生了renderObject级别的修改:
-
大小、位置等修改;
-
需要重绘,如背景颜色修改、字体样式修改。
RenderObject 的状态变更是需要将 RenderObject 的引用放在重绘/重建列表中。
不管是什么级别的变更,只要有变更发生,SchedulerBinding就会发送一个消息到 Flutter 引擎,开始新的一次UI渲染。
当 Flutter 引擎唤醒 SchedulerBinding 的时候,所有的变更都将生效,像魔法一样。
onDrawFrame
在前面,我们提到了 SchedulerBinding两个主要的功能,其中一个就是处理由 Flutter 引擎发出的与帧视图重建相关的请求。下面,我们来看看有关它的详细细节。
当 SchedulerBinding收到 Flutter 引擎发出的 onDrawFrame 时,执行的流程图如下所示:
流程图
-
第1步,元素
WidgetsBinding被执行的时候, Flutter引擎首先考虑的是元素的变化。因为由建造者自己管理元素树,所以绑定控件的时候,会调用建造者的buildScope方法。这个方法中,会将要更改的元素存起来,稍后触发他们进行重建。
rebuild
主要的原则如下:
-
大部分时候,触发元素的重建,会调用控件的
build
方法(Widget build(BuildContext context) {….}),这个方法会返回一个新的控件。
-
如果元素没有子节点,这个元素就被创建完成,反之,会先创建子节点。
-
将新控件与元素引用的子控件进行比较:
-
如果可以被替换, 则更新,并保留子控件;
-
如果不可以被替换,子控件会被移除,并创建一个新的。
创建一个新的控件会创建一个与之对应的新的元素。并且会把元素插入到元素树中去。
下图展示了这个过程:
当控件被创建的时候,需要创建一个元素与之进行关联。
控件与元素的对应关系如下:
举个例子:
StatefulElement在初始化的时候会执行widget.createState
方法,创建并关联对应的状态。RenderObjectElement在元素被加载的时候,会创建一个 RenderObject, 并且会将这个对象加入到渲染树。
-
第2步,渲染对象
一旦完成了脏元素有关的所有动作,元素树就变成了一个稳定结构,是时候考虑渲染到屏幕上了。
渲染绑定用来处理渲染树,控件绑定会调用渲染绑定的 drawFrame方法。
下图展示了 drawFrame
的整个调用流程:
这个过程中,主要执行以下的几个事件:
-
为每一个标记为脏的渲染对象计算新的布局(计算大小和几何形状);
-
使用渲染层,将所有需要重绘的对象重画出来;
-
将生成的场景数据发送给 Flutter 引擎,然后在屏幕中显示出来;
-
最后,Semantics被发送更新到 Flutter 引擎。
在流程的最后,设备屏幕显示的图像将会被更新。
手势处理
手指在屏幕上点击或移动,触发手势,这些会被 GestureBinding处理响应。
当 Flutter 引擎通过 window.onPointerDataPacket
方法发送出手势相关的事件, GestureBinding 会拦截并处理:
-
Flutter 引擎将屏幕位置转换成对应的坐标;
-
拿到坐标上所有渲染出来的View对应的 RenderObject;
-
然后遍历所有的RenderObject ,并把对应事件分发给他们;
-
RenderObject 会等待它能处理的时间并处理它。
动画
最后一部分,我们将聚焦到动画和 Ticker。
当你初始化一个动画的时候,你通常会创建一个 AnimationController 或者类似的控件或者组件。
在Flutter中,与动画相关的所有内容均为Ticker。
Tikcer只做一件事情,它在SchedulerBinding上注册一个回调,当Flutter引擎在下一次可用的时候,会将它唤醒。
当 Flutter 引擎可用,它会触发 SchedulerBinding的 "onBeginFrame" 通知。SchedulerBinding 会遍历并执行所有的 Ticker 的回调。
Ticker 会被对此事件感兴趣的拦截拦截,并对其进行处理。当动画执行结束, Ticker会被标识为“不可用”。因此,Ticker 会告知 SchedulerBinding去执行另一个回调。
总流程图
现在,我们已经知道了 Flutter 内部的工作原理,来看看整体的流程图:
总结构图
BuildContext
最后,如果你还记得不同元素类型的图,你可能已经注意到了元素的签名:
dart abstract class Element extends DiagnosticableTree implements BuildContext { ... }
什么是 BuildContext?
BuildContext是一个接口,它定义一系列元素要实现的方法。
特别的是, BuildCotext 中的 build 方法在 StatelessWidget和StatefulWidget中被使用,还有状态对象在 StatefulWidget 中被使用。
除了一下的两种情况下,其他时候 BuildContext 是没有任何用处的:
控件被重建的时候;
在StatefulWidget链接到你引用的上下文变量的状态。
这就意味着,大部分时候,我们并不需要知道它。
BuildContext 可以用来干什么?
由于BuildContext既与控件相关的元素相对应,也与空间所在树中的位置相对应,因此该BuildContext对以下情况非常有用:
-
获得对应于控件的渲染对象的基准;
-
获取RenderObject的大小;
-
访问树——这是实际使用的所有小部件通常实施该方法的(例如MediaQuery.of(Context),Theme.of(Context))。
小例子
我们知道 BulidContext 也是一个元素,我给你展示一种有关 BuildContext 的使用方法。下面的代码可以使 StatelessWidget更新,但是并不使用 setState 方法,而是使用 BuildContext:
1 void main{2 runApp(MaterialApp(home: TestPage,));3 }45 class TestPage extends StatelessWidget {6 // final because a Widget is immutable (remember?)7 final bag = {"first": true};89 @override10 Widget build(BuildContext context){11 return Scaffold(12 appBar: AppBar(title: Text('Stateless ??')),13 body: Container(14 child: Center(15 child: GestureDetector(16 child: Container(17 width: 50.0,18 height: 50.0,19 color: bag["first"] ? Colors.red : Colors.blue,20 ),21 onTap: {22 bag["first"] = !bag["first"];23 //24 // This is the trick25 //26 (context as Element).markNeedsBuild;27 }28 ),29 ),30 ),31 );32 }33 }
与执行 setState 方法相同,其核心都是执行 _element.markNeedsBuild
方法。
结语
我认为了解Flutter的架构是很有趣的,所有东西都被设计为高效,可扩展且对将来的扩展开放。而且,诸如Widget,Element,BuildContext,RenderObject之类的关键概念并不总是显而易见。
我希望本文对你有用。
原文:https://www.didierboelens.com/2019/09/flutter-internals/
本文为 CSDN 翻译,转载请注明来源出处。
【END】
,