flutter架构全面解析

Flutter 是一个跨平台的 UI 工具集,它的设计初衷,就是允许在各种操作系统上复用同样的代码,例如 iOS 和 Android,同时让应用程序可以直接与底层平台服务进行交互。如此设计是为了让开发者能够在不同的平台上,都能交付拥有原生体验的高性能应用,尽可能地共享复用代码的同时,包容不同平台的差异。

在开发中,Flutter 应用会在一个 VM(程序虚拟机)中运行,从而可以在保留状态且无需重新编译的情况下,热重载相关的更新。对于发行版 (release) ,Flutter 应用程序会直接编译为机器代码(Intel x64 或 ARM 指令集),或者针对 Web 平台的 JavaScript。 Flutter 的框架代码是开源的,遵循 BSD 开源协议,并拥有蓬勃发展的第三方库生态来补充核心库功能。

架构知识图:

flutter架构全面解析_第1张图片

一.架构概览

flutter架构全面解析_第2张图片
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架构全面解析_第3张图片

在构建的阶段,Flutter 会将代码中描述的 widgets 转换成对应的 Element 树,每一个 Widget 都有一个对应的 Element。每一个 Element 代表了树状层级结构中特定位置的 widget 实例。目前有两种 Element 的基本类型:

  • ComponentElement,其他 Element 的宿主。
  • RenderObjectElement,参与布局或绘制阶段的 Element。

flutter架构全面解析_第4张图片

2.布局渲染
很少有应用只绘制单个 widget。因此,有效地排布 widget 的结构及在渲染完成前决定每个 Element 的大小和位置,是所有 UI 框架的重点之一。

在渲染树中,每个节点的基类都是 RenderObject,该基类为布局和绘制定义了一个抽象模型。这是再平凡不过的事情:它并不总是一个固定的大小,甚至不遵循笛卡尔坐标规律(根据该 极坐标系的示例 所示)。每一个 RenderObject 都了解其父节点的信息,但对于其子节点,除了如何 访问 和获得他们的布局约束,并没有更多的信息。这样的设计让 RenderObject 拥有高效的抽象能力,能够处理各种各样的使用场景。

在构建阶段,Flutter 会为 Element 树中的每个 RenderObjectElement 创建或更新其对应的一个从 RenderObject 继承的对象。 RenderObject 实际上是原语:渲染文字的 RenderParagraph、渲染图片的 RenderImage 以及在绘制子节点内容前应用变换的 RenderTransform 是更为上层的实现。

flutter架构全面解析_第5张图片

大部分的 Flutter widget 是由一个继承了 RenderBox 的子类的对象渲染的,它们呈现出的 RenderObject 会在二维笛卡尔空间中拥有固定的大小。 RenderBox 提供了 盒子限制模型,为每个 widget 关联了渲染的最小和最大的宽度和高度。

在进行布局的时候,Flutter 会以 DFS(深度优先遍历)方式遍历渲染树,并 将限制以自上而下的方式 从父节点传递给子节点。子节点若要确定自己的大小,则 必须 遵循父节点传递的限制。子节点的响应方式是在父节点建立的约束内 将大小以自下而上的方式 传递给父节点。

flutter架构全面解析_第6张图片
在遍历完一次树后,每个对象都通过父级约束而拥有了明确的大小,随时可以通过调用 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)中的等效类型。

flutter架构全面解析_第7张图片
具体使用: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包括:
flutter架构全面解析_第8张图片
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 的详解:

  1. 独立性:每个 Isolate 是相互独立的,拥有自己的堆内存和执行线程。这意味着 Isolate 之间的异常不会相互干扰,一个 Isolate 的崩溃不会导致其他 Isolate 崩溃。
  2. 消息通信:Isolate 之间的主要通信方式是通过消息传递。你可以向一个 Isolate
    发送消息,然后接收和处理它的响应。这种通信方式确保了 Isolate 之间的数据隔离。
  3. 创建和启动:你可以通过使用 Isolate.spawn 函数创建和启动新的
    Isolate。这个函数需要两个参数:要执行的函数以及传递给该函数的参数。例如:
Isolate.spawn(isolateFunction, message);
  1. 数据隔离:每个 Isolate 有自己的内存堆,这意味着它们之间的数据是隔离的。如果你需要在不同的 Isolate之间共享数据,你需要通过消息传递的方式来实现。
  2. 并行计算:Isolate 可以用于执行并行计算任务,特别是在多核处理器上。你可以将一个耗时的任务分解成多个 Isolate,在不同的Isolate 中并行执行,从而提高应用程序的性能。
  3. 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();
  });
}

  1. Isolate 生命周期:Isolate 在启动后会一直运行,直到它完成任务或被显式终止。你可以通过调用 Isolate.kill来终止一个 Isolate。

ReceivePort通信原理:

ReceivePort 是 Dart 中用于接收消息的对象,通常用于在不同的 Isolate 之间建立通信通道。它的原理相对简单,主要包括以下几个关键点:

  1. 通信通道建立:当你在一个 Isolate 中创建一个 ReceivePort 实例时,它会分配一个唯一的标识符,并在当前 Isolate 中创建一个消息队列。这个消息队列用于接收其他 Isolate 发送的消息。

  2. SendPort 的获取:在同一个 Isolate 中,你可以使用 ReceivePortsendPort 属性获取一个 SendPort 对象。SendPort 用于将消息发送到包含 ReceivePort 的 Isolate。

  3. 消息发送:当你想要向其他 Isolate 发送消息时,你需要将消息与目标 Isolate 的 SendPort 配对,然后使用 SendPort 发送消息。消息会被编码并传递到目标 Isolate 的消息队列中。

  4. 消息接收:目标 Isolate 使用它的 ReceivePort 实例监听消息队列。当有消息到达时,Isolate 可以从消息队列中取出并解码消息。

  5. 消息处理:一旦消息被接收和解码,目标 Isolate 可以根据消息的内容执行相应的操作。

总之,ReceivePort 的原理就是在一个 Isolate 中创建一个消息队列,然后在同一个 Isolate 中获取一个 SendPort,用于向这个 Isolate 发送消息。其他 Isolate 则使用目标 Isolate 的 ReceivePort 来接收和处理消息。这种消息传递方式实现了 Isolate 之间的通信,但需要注意消息的编码和解码,以及数据的传递方式。

你可能感兴趣的:(Flutter系列,flutter,架构)