Flutter 笔记 | Flutter 核心原理(二)关键类和启动流程

news2024/11/25 0:45:31

Widget、Element、BuildContext 和 RenderObject

Widget

Widget关键类及其子类继承关系如图所示:

在这里插入图片描述

其中,WidgetWidget Tree所有节点的基类。Widget的子类主要分为3类:

  • 第1类是RenderObjectWidget的子类,具体来说又分为SingleChildRenderObjectWidget(单子节点容器)、LeafRenderObjectWidget(叶子节点)、MultiChildRenderObjectWidget(多子节点容器),它们的共同特点是都对应了一个RenderObject 的子类,可以进行LayoutPaint等逻辑。

  • 第2类是StatelessWidgetStatefulWidget,它们是开发者最常用的Widget,自身不具备绘制能力(即不对应Render Object),但是可以组织和配置RenderObjectWidget类型的Widget

  • 第3类是ProxyWidget,具体来说又分为ParentDataWidgetInheritedWidget,它们的特点是为其子节点提供额外的数据。

Element

Element的关键类及其子类继承关系如图所示:

在这里插入图片描述
从图5-2中可以清楚的看到Element的继承关系,它实现了BuildContext接口,图5-2与图5-1相对应,每一个Element都有一个对应的WidgetElement有两个直接的子类 ComponentElementRenderObjectElement,其中 ComponentElement 的两个子类 StatelessElementStatefulElement 就分别对应了 StatelessWidgetStatefulWidget

我们知道最终的UI树其实是由一个个独立的Element节点构成。组件最终的Layout渲染都是通过RenderObject来完成的,从创建到渲染的大体流程是:根据Widget生成Element,然后创建相应的RenderObject并关联到Element.renderObject属性上,最后再通过RenderObject来完成布局排列和绘制。

Element就是Widget在UI树具体位置的一个实例化对象,大多数Element只有唯一的renderObject,但还有一些Element会有多个子节点,如继承自RenderObjectElement的一些类,比如MultiChildRenderObjectElement。最终所有ElementRenderObject构成一棵树,我们称之为”Render Tree“即”渲染树“。

总结一下,我们可以认为Flutter的UI系统包含三棵树:Widget树Element树渲染树。他们的依赖关系是:Element树根据Widget树生成,而渲染树又依赖于Element树,如图所示。

在这里插入图片描述
现在我们重点看一下ElementElement的生命周期如下:

  1. Framework 调用Widget.createElement 创建一个Element实例,记为element

  2. Framework 调用 element.mount(parentElement,newSlot)mount方法中首先调用element所对应WidgetcreateRenderObject方法创建与element相关联的RenderObject对象,然后调用element.attachRenderObject方法将element.renderObject添加到渲染树中插槽指定的位置(这一步不是必须的,一般发生在Element树结构发生变化时才需要重新添加)。插入到渲染树后的element就处于“active”状态,处于“active”状态后就可以显示在屏幕上了(可以隐藏)。

  3. 当有父Widget的配置数据改变时,同时其State.build返回的Widget结构与之前不同,此时就需要重新构建对应的Element树。为了进行Element复用,在Element重新构建前会先尝试是否可以复用旧树上相同位置的elementelement节点在更新前都会调用其对应WidgetcanUpdate方法,如果返回true,则复用旧Element,旧的Element会使用新Widget配置数据更新,反之则会创建一个新的Element

    Widget.canUpdate主要是判断newWidgetoldWidgetruntimeTypekey是否同时相等,如果同时相等就返回true,否则就会返回false。根据这个原理,当我们需要强制更新一个Widget时,可以通过指定不同的Key来避免复用。

  4. 当有祖先Element决定要移除element 时(如Widget树结构发生了变化,导致element对应的Widget被移除),这时该祖先Element就会调用deactivateChild 方法来移除它,移除后element.renderObject也会被从渲染树中移除,然后Framework会调用element.deactivate 方法,这时element状态变为“inactive”状态。

  5. inactive”态的element将不会再显示到屏幕。为了避免在一次动画执行过程中反复创建、移除某个特定element,“inactive”态的element在当前动画最后一帧结束前都会保留,如果在动画执行结束后它还未能重新变成“active”状态,Framework就会调用其unmount方法将其彻底移除,这时element的状态为defunct,它将永远不会再被插入到树中。

  6. 如果element要重新插入到Element树的其他位置,如elementelement的祖先拥有一个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这个纽带将WidgetRenderObject关联起来,了解Element层不仅会帮助开发者对Flutter UI框架有个清晰的认识,而且也会提高自己的抽象能力和设计能力。另外在有些时候,我们必须得直接使用Element对象来完成一些操作,比如获取主题Theme数据。

BuildContext

我们已经知道,StatelessWidgetStatefulWidgetbuild方法都会传一个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调用是发生在StatelessWidgetStatefulWidget对应的StatelessElementStatefulElementbuild方法中,例如在StatelessElement中:

class StatelessElement extends ComponentElement {
  ...
  
  Widget build() => widget.build(this);
  ...
}

同样在StatefulElement 中:

class StatefulElement extends ComponentElement {
  ...	
  
  Widget build() => state.build(this);
  ...
}

发现build传递的参数是this,很明显!这个BuildContext就是StatelessElementStatefulElement本身。但StatelessElementStatefulElement本身并没有实现BuildContext接口,继续跟踪代码,发现它们间接继承自Element类,然后查看Element类定义,发现Element类果然实现了BuildContext接口:

abstract class ComponentElement extends Element {...}
abstract class Element extends DiagnosticableTree implements BuildContext {...}

至此真相大白,BuildContext就是widget对应的Element,所以我们可以通过contextStatelessWidgetStatefulWidgetbuild方法中直接访问Element对象。我们获取主题数据的代码Theme.of(context)内部正是调用了ElementdependOnInheritedWidgetOfExactType()方法。

总结:BuildContext 就是 Element本尊,通过 BuildContext 的方法调用就是在操作 ElementWidget 是外衣,而 Element就是外衣下的裸体。

进阶

我们可以看到Element是Flutter UI框架内部连接widgetRenderObject的纽带,大多数时候开发者只需要关注widget层即可,但是widget层有时候并不能完全屏蔽Element细节,所以Framework在StatelessWidgetStatefulWidget中通过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方法不接收参数,这一点和在StatelessWidgetStatefulWidgetbuild(BuildContext)方法不同。代码中需要用到BuildContext的地方直接用this代替即可,如代码注释 1 处Theme.of(this)参数直接传this即可,因为当前对象本身就是Element实例。

  • text发生改变时,我们调用markNeedsBuild()方法将当前Element标记为dirty即可,标记为dirtyElement会在下一帧中重建。实际上,State.setState()在内部也是调用的markNeedsBuild()方法。

  • 上面代码中build方法返回的仍然是一个widget,这是由于Flutter框架中已经有了widget这一层,并且组件库都已经是以widget的形式提供了,如果在Flutter框架中所有组件都像示例的HomeView一样以Element形式提供,那么就可以用纯Element来构建UI了。HomeViewbuild方法返回值类型就可以是Element了。

如果我们需要将上面代码在现有Flutter框架中跑起来,那么还是得提供一个“适配器”widgetHomeView结合到现有框架中,下面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根节点
  • 另外一个特殊的RenderObjectRenderAbstractViewport,它是一个抽象类。RenderViewport会实现其接口,并间接继承自RenderBox
  • RenderBoxRenderSliver是Flutter中最常见的RenderObjectRenderBox负责行、列等常规布局,而 RenderSliver 负责列表内每个Item的布局。

RenderObject拥有一个parent和一个parentData 属性,parent指向渲染树中自己的父节点,而parentData是一个预留变量,在父组件的布局过程,会确定其所有子组件布局信息(如位置信息,即相对于父组件的偏移),而这些布局信息需要在布局阶段保存起来,因为布局信息在后续的绘制阶段还需要被使用(用于确定组件的绘制位置),而parentData属性的主要作用就是保存布局信息,比如在 Stack 布局中,RenderStack就会将子元素的偏移数据存储在子元素的parentData中(具体可以查看Positioned实现)。

问题:既然有了RenderObject,Flutter框架为什么还要专门提供RenderBoxRenderSliver两个子类?

  • 这是因为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.onBeginFramewindow.onDrawFrame回调,监听刷新事件,绑定Framework绘制调度子系统。
  • PaintingBinding:负责绘制相关的逻辑,绑定绘制库,主要用于处理图片缓存。
  • SemanticsBinding:负责提供无障碍能力,语义化层与Flutter engine的桥梁,主要是辅助功能的底层支持。
  • RendererBinding: 负责Render Tree的最终渲染,持有PipelineOwner对象,提供了window.onMetricsChangedwindow.onTextScaleFactorChanged 等回调。它是渲染树与Flutter engine的桥梁。
  • WidgetsBinding:负责 Flutter 3 棵树的管理,持有BuilderOwner对象,提供了window.onLocaleChangedonBuildScheduled 等回调。它是Flutter widget层与engine的桥梁。

在了解为什么要混入这些Binding之前我们先介绍一下WindowWindow 是 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单例对象后,紧接着会调用WidgetsBindingscheduleAttachRootWidget方法而在其中又调用了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 TreeRender Tree进行创建的入口,需要注意的是,attachRootWidget是通过 Timer.run启动的,这是为了保证所有逻辑都处于消息循环的管理中。

attachRootWidget方法主要负责将根Widget添加到RenderView上,注意,代码中有renderViewrenderViewElement两个变量,renderView是一个RenderObject,它是渲染树的根,而renderViewElementrenderView对应的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,并且将elementwidget 进行关联,即创建出 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将状态重置为idleidle是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方法所触发的初始化流程后,将触发scheduleAttachRootWidgetscheduleWarmUpFrame两个方法,前者负责Render Tree的生成,后者负责首帧渲染的触发。

1. Frame

一次绘制过程,我们称其为一帧(frame)。我们之前说的 Flutter 可以实现60fps(Frame Per-Second)就是指一秒钟最多可以触发 60 次重绘,FPS 值越大,界面就越流畅。这里需要说明的是 Flutter中 的 frame 概念并不等同于屏幕刷新帧(frame),因为Flutter UI 框架的 frame 并不是每次屏幕刷新都会触发,这是因为,如果 UI 在一段时间不变,那么每次屏幕刷新都重新走一遍渲染流程是不必要的,因此,Flutter 在第一帧渲染结束后会采取一种主动请求 frame 的方式来实现只有当UI可能会改变时才会重新走渲染流程。

  1. Flutter 在 window 上注册一个 onBeginFrame和一个 onDrawFrame 回调,在onDrawFrame 回调中最终会调用 drawFrame
  2. 当我们调用 window.scheduleFrame() 方法之后,Flutter引擎会在合适的时机(可以认为是在屏幕下一次刷新之前,具体取决于Flutter引擎的实现)来调用onBeginFrameonDrawFrame

可见,只有主动调用scheduleFrame(),才会执行 drawFrame。所以,我们在Flutter 中的提到 frame 时,如无特别说明,则是和 drawFrame() 的调用对应,而不是和屏幕的刷新频率对应。

2. Flutter 调度过程 SchedulerPhase

Flutter 应用执行过程简单来讲分为 idleframe 两种状态,idle 状态代表没有 frame 处理,如果应用状态改变需要刷新 UI,则需要通过scheduleFrame()去请求新的 frame,当 frame 到来时,就进入了frame状态,整个Flutter应用生命周期就是在 idleframe 两种状态间切换。

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 到来时,调用到 WidgetsBindingdrawFrame() 方法,我们来看看它的实现:


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件事:

  1. 重新构建widget树。
  2. 更新布局。
  3. 更新“层合成”信息。
  4. 重绘。
  5. 上屏:将绘制的产物显示在屏幕上。

我们称上面的5步为 rendering pipeline,中文翻译为 “渲染流水线” 或 “渲染管线”。

下面我们以 setState 的执行更新的流程为例先对整个更新流程有一个大概的了解。

setState 执行流程

setState 调用后:

  • 首先调用当前 elementmarkNeedsBuild 方法,将当前 element_dirty标记为 true
  • 接着调用 scheduleBuildFor,将当前 element 添加到BuildOwner_dirtyElements 列表中。
  • 同时会请求一个新的 frame,随后会绘制新的 frameonBuildScheduled->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;
  }
}
  1. 重新构建 widget 树:如果 dirtyElements 列表不为空,则遍历该列表,调用每一个elementrebuild方法重新构建新的widget(树),由于新的widget(树)使用新的状态构建,所以可能导致widget布局信息(占用的空间和位置)发生变化,如果发生变化,则会调用其renderObjectmarkNeedsLayout方法,该方法会从当前节点向父级查找,直到找到一个relayoutBoundary的节点,然后会将它添加到一个全局的nodesNeedingLayout列表中;如果直到根节点也没有找到relayoutBoundary,则将根节点添加到nodesNeedingLayout列表中。

  2. 更新布局:遍历nodesNeedingLayout数组,对每一个renderObject重新布局(调用其layout方法),确定新的大小和偏移。layout方法中会调用markNeedsPaint(),该方法和 markNeedsLayout 方法功能类似,也会从当前节点向父级查找,直到找到一个isRepaintBoundary属性为true的父节点,然后将它添加到一个全局的nodesNeedingPaint列表中;由于根节点(RenderView)的 isRepaintBoundarytrue,所以必会找到一个。查找过程结束后会调用 buildOwner.requestVisualUpdate 方法,该方法最终会调用scheduleFrame(),该方法中会先判断是否已经请求过新的frame,如果没有则请求一个新的frame

  3. 更新合成信息:先忽略。

  4. 更新绘制:遍历nodesNeedingPaint列表,调用每一个节点的paint方法进行重绘,绘制过程会生成Layer。需要说明一下,flutter中绘制结果是保存在Layer中的,也就是说只要Layer不释放,那么绘制的结果就会被缓存,因此,Layer可以跨frame来缓存绘制结果,避免不必要的重绘开销。Flutter框架绘制过程中,遇到isRepaintBoundarytrue 的节点时,才会生成一个新的Layer。可见LayerrenderObject 不是一一对应关系,父子节点可以共享,这个我们会在随后的一个试验中来验证。当然,如果是自定义组件,我们可以在renderObject中手动添加任意多个 Layer,这通常用于只需一次绘制而随后不会发生变化的绘制元素的缓存场景,这个随后我们也会通过一个例子来演示。

  5. 上屏:绘制完成后,我们得到的是一棵Layer树,最后我们需要将Layer树中的绘制信息在屏幕上显示。我们知道Flutter是自实现的渲染引擎,因此,我们需要将绘制信息提交给Flutter engine,而renderView.compositeFrame 正是完成了这个使命。

以上,便是setState调用到UI更新的大概更新过程,实际的流程会更复杂一些,比如在build过程中是不允许再调用setState的,框架需要做一些检查。又比如在frame中会涉及到动画的的调度、在上屏时会将所有的Layer添加到场景(Scene)对象后,再渲染Scene。

setState 执行时机问题

setState 会触发 build,而 build 是在执行 persistentCallbacks 阶段执行的,因此只要不是在该阶段执行 setState 就绝对安全,但是这样的粒度太粗,比如在transientCallbacksmidFrameMicrotasks 阶段,如果应用状态发生变化,最好的方式是只将组件标记为 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内核源码剖析》

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/588022.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

08. 算法之递归算法

前言 递归&#xff0c;字面意思是递出去&#xff0c;拿回来&#xff0c;通过不断递过去&#xff0c;拿回来的过程&#xff0c;将每次调用结果保存起来&#xff0c;最后实现循环调用。递归在某些情况下会极大降低我们编程的复杂度。是软件开发工程师一定要掌握的技能。 1. 概念…

Linux—实操篇:vi和vim编辑器

1.vi和vim基本介绍 Linux系统会内置vi文本编辑器 vim具有程序编写的能力&#xff0c;可以看做是vi的增强版本&#xff0c;被程序员广泛使用 2、vi和vim常用的三种模式 2.1、正常模式 以vim打开一个档案就直接进入一般模式了(这是默认的模式)。在这个模式中&#xff0c;你可…

溯源取证 - 流量分析 中等难度

使用工具&#xff1a; Brim 链接: https://www.brimdata.io/download/ Networkminer 链接: https://www.netresec.com/?pageNetworkMiner Wireshark Strings ida pro 知识点&#xff1a; 通过本篇文章&#xff0c;学习ssh协议特点、学习流量导出文件、学习简单的逆向分析、…

卫星定位北斗芯片AT6558一款高性能BDS/GNSS多模卫星导航接收机SOC单芯片

1 芯片简介 AT6558R是一款高性能BDS/GNSS多模卫星导航接收机SOC单芯片,片上集成射频前端&#xff0c; 数字基带处理器&#xff0c;32位的RISCCPU&#xff0c;电源管理功能。 芯片支持多种卫星导航系统&#xff0c;包括中国的北斗卫星导航系统BDS&#xff0c;美国的GPS,俄罗斯 的…

Mysql DDL执行方式-pt-osc介绍 | 京东云技术团队

1 引言 大家好&#xff0c;接着上次和大家一起学习了《MySQL DDL执行方式-Online DDL介绍》&#xff0c;那么今天接着和大家一起学习另一种MySQL DDL执行方式之pt-soc。 在MySQL使用过程中&#xff0c;根据业务的需求对表结构进行变更是个普遍的运维操作&#xff0c;这些称为…

elasticsearch分词,排序,分页,高亮简单示例

目录 1. 创建ES实体2. 创建查询实体3. 查询方法实现3.1 总体代码3.2 构建查询条件3.2.1 关键词分词 3.3 高亮处理4.总体查询代码 记&#xff0c;写一个简单的es分词demo,es版本6.8.12 如果使用es7有些方法可能会有所改变&#xff0c;请参考7的文档 es安装教程&#xff1a;http:…

SUSE系统上安装HANA

一:安装SUSE操作系统 1.1 准备安装镜像 SLE-15-SP1-安装程序-DVD-x86_64-GM-DVD1 SLE-15-SP1-软件包-x86_64-GM-DVD1 SAP HANA安装文件 IMDB_SERVER20_032_0-80002031.SAR 1.2 引导系统 1.3 选择要安装的产品 SUSE Linux Enterprise Server for SAP Applications 15 SP…

Stable Diffusion教程(5) - 文生图教程

配套视频教程&#xff1a; https://v.douyin.com/UyHNfYG/ 文生图界面标注如下 1 提示词和反向提示词 提示词内输入的东西就是你想要画的东西&#xff0c;反向提示词内输入的就是你不想要画的东西 提示框内只能输入英文&#xff0c;所有符号都要使用英文半角&#xff0c;词语…

企业级信息系统开发讲课笔记4.5 掌握Spring Boot多环境配置

文章目录 零、学习目标一、项目进行多环境配置的必要性二、使用Profile文件进行多环境配置&#xff08;一&#xff09;创建Spring Boot项目&#xff08;二&#xff09;创建多环境配置文件1、全局配置文件改名2、模拟开发环境3、模拟测试环境4、模拟生产环境 &#xff08;三&…

Unity基础 音频组件以及音频播放

在游戏开发中&#xff0c;声音是一个重要的环节。Unity中的声音组件可以帮助开发者轻松地控制游戏中音频的播放、音量、循环等属性&#xff0c;从而实现更好的游戏体验。本文将详细介绍Unity声音组件的相关概念和技术&#xff0c;以及其在游戏、影视等领域的广泛应用和发展前景…

银行联行号-联行号api接口-联行号数据源

接口地址&#xff1a; https://登录后显示/api/180/348(支持:http/https) 数据源&#xff1a;https://www.wapi.cn/source/8.html 网站地址&#xff1a;https://www.wapi.cn 返回格式&#xff1a;json,xml 请求方式&#xff1a;GET,POST 请求说明&#xff1a; 银行联行号-联行…

数据集:T-Drive(北京出租车轨迹数据)

1 数据来源 T-Drive trajectory data sample - Microsoft Research 2 数据介绍 数据集包含了2008年2月2日至2月8日期间在北京市内的10,357辆出租车的GPS轨迹。总共包含约1500万个GPS点&#xff0c;轨迹总里程达到了900万公里。 图1显示了两个连续点之间的时间间隔和距离间隔…

Apache 配置和应用

目录 构建虚拟 Web 主机 Options指令解释 Options指令常用选项 AllowOverride指令解释&#xff1a; 地址限制策略&#xff1a; httpd服务支持的虚拟主机类型包括以下三种: 基于域名的虚拟主机 1&#xff0e;为虚拟主机提供域名解析 2.为虚拟主机准备网页文档 3.添加虚拟…

【服务器】springboot实现HTTP服务监听

文章目录 前言1. 本地环境搭建1.1 环境参数1.2 搭建springboot服务项目 2. 内网穿透2.1 安装配置cpolar内网穿透2.1.1 windows系统2.1.2 linux系统 2.2 创建隧道映射本地端口2.3 测试公网地址 3. 固定公网地址3.1 保留一个二级子域名3.2 配置二级子域名3.2 测试使用固定公网地址…

Mysql 异常,“Cause: com.mysql.cj.jdbc.exceptions.MySQLTimeoutException”

Cause: com.mysql.cj.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request 简言&#xff1a;这种异常从字面翻译过来&#xff1a;mysql 请求链接超时&#xff0c;具体超时是什么原因导致的&#xff0c;可以根据情况分析下。 异常详…

【黄啊码】PHP商城中的积分任务系统实现

大家好&#xff0c;我是黄啊码&#xff0c;前几天有位小伙伴问我&#xff0c;商城中的任务系统是怎么实现的&#xff1f; 积分作为一种营销手段&#xff0c;被广泛运用于线上/线下的产品中&#xff0c;以此来增加用户对于产品的粘性。比如天猫积分可以用来兑换商品&#xff0c…

uni-app通过vue.config.js在项目中配置跨域代理

其实这个 如果你用nginx去配肯定再好不过 不过 一般大家也都不想把开发环境弄那么复杂 最好还是在项目中配置 那么 我们选择项目跟目录右键 选择 使用命令行窗口打开所在目录 在新弹出的命令行中引入依赖 npm install http-proxy-middleware --save-dev然后我们的依赖就进来…

【通信接口】CAN总线协议

目录 一、什么是CAN 1、CAN 的概念 2、节点构成&#xff08;CAN 总线通信模型&#xff09; 3、差分信号&#xff08;电平特性&#xff09; 4、CAN 总线的特点 二、CAN 总线协议的通信过程 1、发送过程 2、接收过程 3、概括 三、CAN 通信帧的分类 一、什么是CAN 1、C…

易基因:全基因组ChIP-seq分析揭示细菌转录因子PhoB的基因内结合位点|mBio

大家好&#xff0c;这里是专注表观组学十余年&#xff0c;领跑多组学科研服务的易基因。 细菌编码许多转录因子&#xff08;transcription factor&#xff0c;TF&#xff09;&#xff0c;这些转录因子通过与启动子周围的DNA结合并调控RNA聚合酶&#xff08;RNAP&#xff09;全…

微服务架构打造的供应链系统、采购配送系统,支持SaaS模式

一、开源项目简介 haohan-scm 介绍 基于pig微服务架构打造 供应链系统&#xff0c;采购配送系统。为客户提供仓储管理、订单管理、打单、货源采购、分拣、配送等系统功能。 二、开源协议 使用AGPL-3.0开源协议 三、界面展示 系统截图 四、功能概述 基于pig微服务架构打…