Flutter 是一个跨平台的 UI 工具集,它的设计初衷,就是允许在各种操作系统上复用同样的代码,例如 iOS 和 Android,同时让应用程序可以直接与底层平台服务进行交互。如此设计是为了让开发者能够在不同的平台上,都能交付拥有原生体验的高性能应用,尽可能地共享复用代码的同时,包容不同平台的差异。
在开发中,Flutter 应用会在一个 VM(程序虚拟机)中运行,从而可以在保留状态且无需重新编译的情况下,热重载相关的更新。对于发行版 (release) ,Flutter 应用程序会直接编译为机器代码(Intel x64 或 ARM 指令集),或者针对 Web 平台的 JavaScript。 Flutter 的框架代码是开源的,遵循 BSD 开源协议,并拥有蓬勃发展的第三方库生态来补充核心库功能。
架构知识图:
一.架构概览
Flutter 从上到下可以分为三层:框架层、引擎层和嵌入层
1.框架层
底下两层(Foundation 和 Animation、Painting、Gestures)在 Google 的一些视频中被合并为一个dart UI层,对应的是Flutter中的dart:ui包,它是 Flutter Engine 暴露的底层UI库,提供动画、手势及绘制能力。
Rendering 层,即渲染层,这一层是一个抽象的布局层,它依赖于 Dart UI 层,渲染层会构建一棵由可渲染对象的组成的渲染树,当动态更新这些对象时,渲染树会找出变化的部分,然后更新渲染。渲染层可以说是Flutter 框架层中最核心的部分,它除了确定每个渲染对象的位置、大小之外还要进行坐标变换、绘制(调用底层 dart:ui )。
Widgets 层是 Flutter 提供的的一套基础组件库,在基础组件库之上,Flutter 还提供了 Material 和 Cupertino 两种视觉风格的组件库,它们分别实现了 Material 和 iOS 设计规范。
2. 引擎层
Engine,即引擎层。毫无疑问是 Flutter 的核心, 该层主要是 C++ 实现,其中包括了 Skia 引擎、Dart 运行时(Dart runtime)、文字排版引擎等。在代码调用 dart:ui库时,调用最终会走到引擎层,然后实现真正的绘制和显示。
3. 嵌入层
Embedder,即嵌入层。Flutter 最终渲染、交互是要依赖其所在平台的操作系统 API,嵌入层主要是将 Flutter 引擎 ”安装“ 到特定平台上。嵌入层采用了当前平台的语言编写,例如 Android 使用的是 Java 和 C++, iOS 和 macOS 使用的是 Objective-C 和 Objective-C++,Windows 和 Linux 使用的是 C++。 Flutter 代码可以通过嵌入层,以模块方式集成到现有的应用中,也可以作为应用的主体。Flutter 本身包含了各个常见平台的嵌入层,假如以后 Flutter 要支持新的平台,则需要针对该新的平台编写一个嵌入层。
二.渲染模型
你可能思考过:既然 Flutter 是一个跨平台的框架,那么它如何提供与原生平台框架相当的性能?
让我们从安卓原生应用的角度开始思考。当你在编写绘制的内容时,你需要调用 Android 框架的 Java 代码。 Android 的系统库提供了可以将自身绘制到 Canvas 对象的组件,接下来 Android 就可以使用由 C/C++ 编写的 Skia 图像引擎,调用 CPU 和 GPU 完成在设备上的绘制。
通常来说,跨平台框架都会在 Android 和 iOS 的 UI 底层库上创建一层抽象,该抽象层尝试抹平各个系统之间的差异。这时,应用程序的代码常常使用 JavaScript 等解释型语言来进行编写,这些代码会与基于 Java 的 Android 和基于 Objective-C 的 iOS 系统进行交互,最终显示 UI 界面。所有的流程都增加了显著的开销,在 UI 和应用逻辑有繁杂的交互时更为如此。
相比之下,Flutter 通过绕过系统 UI 组件库,使用自己的 widget 内容集,削减了抽象层的开销。用于绘制 Flutter 图像内容的 Dart 代码被编译为机器码,并使用 Skia 进行渲染。 Flutter 同时也嵌入了自己的 Skia 副本(未来会迁移到 Impeller),让开发者能在设备未更新到最新的系统时,也能跟进升级自己的应用,保证稳定性并提升性能。
1.构建过程
从 Widget 到 Element
Widget build(BuildContext context) {return Container(color: Colors.blue,child: Row(children: [Image.network('https://www.example.com/1.png'),const Text('A'),],),);}
当 Flutter 需要绘制这段代码片段时,框架会调用 build() 方法,返回一棵基于当前应用状态来绘制 UI 的 widget 子树。在这个过程中,build() 方法可能会在必要时,根据状态引入新的 widget。在上面的例子中,Container 的 color 和 child 就是典型的例子。我们可以查看 Container 的 源代码,你会看到当 color 属性不为空时,ColoredBox 会被加入用于颜色布局。
if (color != null)current = ColoredBox(color: color!, child: current);
与之对应的,Image 和 Text 在构建过程中也会引入 RawImage 和 RichText。如此一来,最终生成的 widget 结构比代码表示的层级更深,在该场景中如下图
在构建的阶段,Flutter 会将代码中描述的 widgets 转换成对应的 Element 树,每一个 Widget 都有一个对应的 Element。每一个 Element 代表了树状层级结构中特定位置的 widget 实例。目前有两种 Element 的基本类型:
- ComponentElement,其他 Element 的宿主。
- RenderObjectElement,参与布局或绘制阶段的 Element。
2.布局渲染
很少有应用只绘制单个 widget。因此,有效地排布 widget 的结构及在渲染完成前决定每个 Element 的大小和位置,是所有 UI 框架的重点之一。
在渲染树中,每个节点的基类都是 RenderObject,该基类为布局和绘制定义了一个抽象模型。这是再平凡不过的事情:它并不总是一个固定的大小,甚至不遵循笛卡尔坐标规律(根据该 极坐标系的示例 所示)。每一个 RenderObject 都了解其父节点的信息,但对于其子节点,除了如何 访问 和获得他们的布局约束,并没有更多的信息。这样的设计让 RenderObject 拥有高效的抽象能力,能够处理各种各样的使用场景。
在构建阶段,Flutter 会为 Element 树中的每个 RenderObjectElement 创建或更新其对应的一个从 RenderObject 继承的对象。 RenderObject 实际上是原语:渲染文字的 RenderParagraph、渲染图片的 RenderImage 以及在绘制子节点内容前应用变换的 RenderTransform 是更为上层的实现。
大部分的 Flutter widget 是由一个继承了 RenderBox 的子类的对象渲染的,它们呈现出的 RenderObject 会在二维笛卡尔空间中拥有固定的大小。 RenderBox 提供了 盒子限制模型,为每个 widget 关联了渲染的最小和最大的宽度和高度。
在进行布局的时候,Flutter 会以 DFS(深度优先遍历)方式遍历渲染树,并 将限制以自上而下的方式 从父节点传递给子节点。子节点若要确定自己的大小,则 必须 遵循父节点传递的限制。子节点的响应方式是在父节点建立的约束内 将大小以自下而上的方式 传递给父节点。
在遍历完一次树后,每个对象都通过父级约束而拥有了明确的大小,随时可以通过调用 paint() 进行渲染。
所有 RenderObject 的根节点是 RenderView,代表了渲染树的总体输出。当平台需要渲染新的一帧内容时(例如一个 vsync 信号或者一个纹理的更新完成),会调用一次 compositeFrame() 方法,它是 RenderView 的一部分。该方法会创建一个 SceneBuilder 来触发当前画面的更新。当画面更新完毕,RenderView 会将合成的画面传递给 dart:ui 中的 Window.render() 方法,控制 GPU 进行渲染。
3.Flutter渲染原生内容
由于 Flutter 的内容会绘制在单一的纹理内,并且 widget 树是完全在内部的,因此在 Flutter 的内部模型中无法存在 Android 视图之类的内容,也无法与 Flutter 的 widget 交错渲染对于需要在 Flutter 应用中展示原生组件(例如内置浏览器)的开发者来说,这是一个问题。
Flutter 通过引入了平台 widget (AndroidView 和 UiKitView) 解决了这个问题,开发者可以在每一种平台上嵌入此类内容。平台视图可以与其他的 Flutter 内容集成4。这些 widget 充当了底层操作系统与 Flutter 之间的桥梁。例如在 Android 上,AndroidView 主要提供了三项功能:
- 拷贝原生视图渲染的图形纹理,在 Flutter 每帧渲染时提交给 Flutter 渲染层进行合成。
- 响应命中测试和输入手势,将其转换为等效的原生输入事件。
- 创建类似的可访问性树,并在原生层与 Flutter 层之间传递命令和响应。
三.通信
对于移动端和桌面端应用而言,Flutter 提供了通过 平台通道 调用自定义代码的能力,这是一种非常简单的在宿主应用之间让 Dart 代码与平台代码通信的机制。通过创建一个常用的通道(封装通道名称和编码),开发者可以在 Dart 与使用 Kotlin 和 Swift 等语言编写的平台组件之间发送和接收消息。数据会由 Dart 类型(例如 Map)序列化为一种标准格式,然后反序列化为 Kotlin(例如 HashMap)或者 Swift(例如 Dictionary)中的等效类型。
具体使用:https://blog.csdn.net/wang_yong_hui_1234/article/details/129852460
四.线程
Flutter Engine线程的创建和管理是由embedder负责的。
Flutter Engine要求Embeder提供四个Task Runner。尽管Flutter Engine不在乎Runner具体跑在哪个线程,但是它需要线程配置在整一个生命周期里面保持稳定。也就是说一个Runner最好始终保持在同一线程运行。这四个主要的Task Runner包括:
Platform Task Runner
Flutter Engine的主Task Runner,运行Platform Task Runner的线程可以理解为是主线程。类似于Android Main Thread或者iOS的Main Thread。但是我们要注意Platform Task Runner和iOS之类的主线程还是有区别的。
对于Flutter Engine来说Platform Runner所在的线程跟其它线程并没有实质上的区别,只不过我们人为赋予它特定的含义便于理解区分。实际上我们可以同时启动多个Engine实例,每个Engine对应一个Platform Runner,每个Runner跑在各自的线程里。这也是Fuchsia(Google正在开发的操作系统)里Content Handler的工作原理。一般来说,一个Flutter应用启动的时候会创建一个Engine实例,Engine创建的时候会创建一个线程供Platform Runner使用。
跟Flutter Engine的所有交互(接口调用)必须发生在Platform Thread,试图在其它线程中调用Flutter Engine会导致无法预期的异常。这跟iOS UI相关的操作都必须在主线程进行相类似。需要注意的是在Flutter Engine中有很多模块都是非线程安全的。一旦引擎正常启动运行起来,所有引擎API调用都将在Platform Thread里发生。
Platform Runner所在的Thread不仅仅处理与Engine交互,它还处理来自平台的消息。这样的处理比较方便的,因为几乎所有引擎的调用都只有在Platform Thread进行才能是安全的,Native Plugins不必要做额外的线程操作就可以保证操作能够在Platform Thread进行。如果Plugin自己启动了额外的线程,那么它需要负责将返回结果派发回Platform Thread以便Dart能够安全地处理。规则很简单,对于Flutter Engine的接口调用都需保证在Platform Thread进行。
需要注意的是,阻塞Platform Thread不会直接导致Flutter应用的卡顿(跟iOS android主线程不同)。尽管如此,平台对Platform Thread还是有强制执行限制。所以建议复杂计算逻辑操作不要放在Platform Thread而是放在其它线程(不包括我们现在讨论的这个四个线程)。其他线程处理完毕后将结果转发回Platform Thread。长时间卡住Platform Thread应用有可能会被系统Watchdog强行杀死。
UI Task Runner Thread(Dart Runner)
UI Task Runner被Flutter Engine用于执行Dart root isolate代码(isolate我们后面会讲到,姑且先简单理解为Dart VM里面的线程)。Root isolate比较特殊,它绑定了不少Flutter需要的函数方法。Root isolate运行应用的main code。引擎启动的时候为其增加了必要的绑定,使其具备调度提交渲染帧的能力。对于每一帧,引擎要做的事情有:
- Root isolate通知Flutter Engine有帧需要渲染。
- Flutter Engine通知平台,需要在下一个vsync的时候得到通知。
- 平台等待下一个vsync
- 对创建的对象和Widgets进行Layout并生成一个Layer Tree,这个Tree马上被提交给Flutter Engine。当前阶段没有进行任何光栅化,这个步骤仅是生成了对需要绘制内容的描述。
- 创建或者更新Tree,这个Tree包含了用于屏幕上显示Widgets的语义信息。这个东西主要用于平台相关的辅助Accessibility元素的配置和渲染。
除了渲染相关逻辑之外Root Isolate还是处理来自Native Plugins的消息响应,Timers,Microtasks和异步IO。
我们看到Root Isolate负责创建管理的Layer Tree最终决定什么内容要绘制到屏幕上。因此这个线程的过载会直接导致卡顿掉帧。
如果确实有无法避免的繁重计算,建议将其放到独立的Isolate去执行,比如使用compute关键字或者放到非Root Isolate,这样可以避免应用UI卡顿。但是需要注意的是非Root Isolate缺少Flutter引擎需要的一些函数绑定,你无法在这个Isolate直接与Flutter Engine交互。所以只在需要大量计算的时候采用独立Isolate。
GPU Task Runner
GPU Task Runner被用于执行设备GPU的相关调用。UI Task Runner创建的Layer Tree信息是平台不相关,也就是说Layer Tree提供了绘制所需要的信息,具体如何实现绘制取决于具体平台和方式,可以是OpenGL,Vulkan,软件绘制或者其他Skia配置的绘图实现。GPU Task Runner中的模块负责将Layer Tree提供的信息转化为实际的GPU指令。GPU Task Runner同时也负责配置管理每一帧绘制所需要的GPU资源,这包括平台Framebuffer的创建,Surface生命周期管理,保证Texture和Buffers在绘制的时候是可用的。
基于Layer Tree的处理时长和GPU帧显示到屏幕的耗时,GPU Task Runner可能会延迟下一帧在UI Task Runner的调度。一般来说UI Runner和GPU Runner跑在不同的线程。存在这种可能,UI Runner在已经准备好了下一帧的情况下,GPU Runner却还正在向GPU提交上一帧。这种延迟调度机制确保不让UI Runner分配过多的任务给GPU Runner。
前面我们提到GPU Runner可以导致UI Runner的帧调度的延迟,GPU Runner的过载会导致Flutter应用的卡顿。一般来说用户没有机会向GPU Runner直接提交任务,因为平台和Dart代码都无法跑进GPU Runner。但是Embeder还是可以向GPU Runner提交任务的。因此建议为每一个Engine实例都新建一个专用的GPU Runner线程。
IO Task Runner
前面讨论的几个Runner对于执行任务的类型都有比较强的限制。Platform Runner过载可能导致系统WatchDog强杀,UI和GPU Runner过载则可能导致Flutter应用的卡顿。但是GPU线程有一些必要操作是比较耗时间的,比如IO,而这些操作正是IO Runner需要处理的。
IO Runner的主要功能是从图片存储(比如磁盘)中读取压缩的图片格式,将图片数据进行处理为GPU Runner的渲染做好准备。在Texture的准备过程中,IO Runner首先要读取压缩的图片二进制数据(比如PNG,JPEG),将其解压转换成GPU能够处理的格式然后将数据上传到GPU。这些复杂操作如果跑在GPU线程的话会导致Flutter应用UI卡顿。但是只有GPU Runner能够访问GPU,所以IO Runner模块在引擎启动的时候配置了一个特殊的Context,这个Context跟GPU Runner使用的Context在同一个ShareGroup。事实上图片数据的读取和解压是可以放到一个线程池里面去做的,但是这个Context的访问只能在特定线程才能保证安全。这也是为什么需要有一个专门的Runner来处理IO任务的原因。获取诸如ui.Image这样的资源只有通过async call,当这个调用发生的时候Flutter Framework告诉IO Runner进行刚刚提到的那些图片异步操作。这样GPU Runner可以使用IO Runner准备好的图片数据而不用进行额外的操作。
用户操作,无论是Dart Code还是Native Plugins都是没有办法直接访问IO Runner。尽管Embeder可以将一些一般复杂任务调度到IO Runner,这不会直接导致Flutter应用卡顿,但是可能会导致图片和其它一些资源加载的延迟间接影响性能。所以建议为IO Runner创建一个专用的线程。
五.单线程模型
首先我们要知道Dart是单线程模型,Dart 同一时刻只执行一个操作,其他操作在该操作之后执行,这意味着只要一个操作正在执行,它就不会被其他 Dart 代码中断。
Dart 是如何管理操作序列的执行的呢?
当你启动一个 Flutter(或任何 Dart)应用时,将创建并启动一个新的线程进程(在 Dart 中为 「Isolate」)。该线程将是你在整个应用中唯一需要关注的。
所以,此线程创建后,Dart 会自动:
- 初始化 2 个 FIFO(先进先出)队列(「MicroTask」和 「Event」);
- 并且当该方法执行完成后,执行 main() 方法;
- 启动事件循环。
MicroTask 队列
-
执行时机:微任务会在当前执行栈空闲后、事件队列中的任务执行之前执行。也就是说,微任务会在下一个事件循环周期之前执行。
-
使用场景:微任务通常用于需要在页面渲染前执行的任务,或者需要优先于其他异步任务执行的情况。比如,Future的回调、async/await 中 await 后面的代码都属于微任务。
-
执行顺序:多个微任务会按照它们被添加到队列中的顺序执行,先进先出(FIFO)原则
使用代码如下:
print("Start");Future.microtask(() {print("Microtask 1");
}).then((_) {print("Microtask 2");
});print("End");// 输出顺序:
// "Start"
// "End"
// "Microtask 1"
// "Microtask 2"
Event 队列
- 执行时机:事件队列包含了各种异步事件,例如用户交互、网络请求、定时器等。事件队列中的任务会在当前执行栈执行完毕后逐个执行。
- 使用场景:事件队列用于存放需要在响应外部事件时执行的任务,如点击事件、网络请求回调等。
- 执行顺序:事件队列中的任务会按照它们被添加到队列中的顺序执行,也是先进先出(FIFO)原则。
print("Start");Future.delayed(Duration(seconds: 1), () {print("Event 1");
}).then((_) {print("Event 2");
});print("End");// 输出顺序:
// "Start"
// "End"
// "Event 1"
// "Event 2"
总结:
微任务用于处理优先级较高、需要尽快执行的任务,而事件队列用于处理响应事件、网络请求等异步任务。在 Dart 中,事件循环会不断地从微任务队列和事件队列中取出任务执行,这就是 Dart 异步编程的核心机制。
六.Isolate
Dart 中的 Isolate 是一种轻量级的独立执行单元,允许你在一个 Dart 程序中并行执行代码。每个 Isolate 都有自己的内存空间和执行上下文,它们之间不能直接共享内存,但可以通过消息传递进行通信。下面是一些关于 Dart Isolate 的详解:
- 独立性:每个 Isolate 是相互独立的,拥有自己的堆内存和执行线程。这意味着 Isolate 之间的异常不会相互干扰,一个 Isolate 的崩溃不会导致其他 Isolate 崩溃。
- 消息通信:Isolate 之间的主要通信方式是通过消息传递。你可以向一个 Isolate
发送消息,然后接收和处理它的响应。这种通信方式确保了 Isolate 之间的数据隔离。 - 创建和启动:你可以通过使用 Isolate.spawn 函数创建和启动新的
Isolate。这个函数需要两个参数:要执行的函数以及传递给该函数的参数。例如:
Isolate.spawn(isolateFunction, message);
- 数据隔离:每个 Isolate 有自己的内存堆,这意味着它们之间的数据是隔离的。如果你需要在不同的 Isolate之间共享数据,你需要通过消息传递的方式来实现。
- 并行计算:Isolate 可以用于执行并行计算任务,特别是在多核处理器上。你可以将一个耗时的任务分解成多个 Isolate,在不同的Isolate 中并行执行,从而提高应用程序的性能。
- Isolate 间通信:Isolate 之间的消息通信是异步的。你可以使用 ReceivePort 和 SendPort来建立通信通道。一个 Isolate可以通过 SendPort 发送消息,另一个 Isolate 则通过 ReceivePort来接收消息。
import 'dart:isolate';void myIsolateFunction(SendPort sendPort) {sendPort.send('Hello from Isolate!');
}void main() {ReceivePort receivePort = ReceivePort();Isolate.spawn(myIsolateFunction, receivePort.sendPort);receivePort.listen((message) {print('Received message: $message');receivePort.close();});
}
- Isolate 生命周期:Isolate 在启动后会一直运行,直到它完成任务或被显式终止。你可以通过调用 Isolate.kill来终止一个 Isolate。
ReceivePort通信原理:
ReceivePort
是 Dart 中用于接收消息的对象,通常用于在不同的 Isolate 之间建立通信通道。它的原理相对简单,主要包括以下几个关键点:
-
通信通道建立:当你在一个 Isolate 中创建一个
ReceivePort
实例时,它会分配一个唯一的标识符,并在当前 Isolate 中创建一个消息队列。这个消息队列用于接收其他 Isolate 发送的消息。 -
SendPort 的获取:在同一个 Isolate 中,你可以使用
ReceivePort
的sendPort
属性获取一个SendPort
对象。SendPort
用于将消息发送到包含ReceivePort
的 Isolate。 -
消息发送:当你想要向其他 Isolate 发送消息时,你需要将消息与目标 Isolate 的
SendPort
配对,然后使用SendPort
发送消息。消息会被编码并传递到目标 Isolate 的消息队列中。 -
消息接收:目标 Isolate 使用它的
ReceivePort
实例监听消息队列。当有消息到达时,Isolate 可以从消息队列中取出并解码消息。 -
消息处理:一旦消息被接收和解码,目标 Isolate 可以根据消息的内容执行相应的操作。
总之,ReceivePort
的原理就是在一个 Isolate 中创建一个消息队列,然后在同一个 Isolate 中获取一个 SendPort
,用于向这个 Isolate 发送消息。其他 Isolate 则使用目标 Isolate 的 ReceivePort
来接收和处理消息。这种消息传递方式实现了 Isolate 之间的通信,但需要注意消息的编码和解码,以及数据的传递方式。