Flutter 笔记 | Flutter 核心原理(二)关键类和启动流程
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就是外衣下的裸体。
BuildContext 的另一层含义
关于 BuildContext 的另一层含义就是,它是对Widget在Widget树中的位置的引用,它包含了关于Widget在Widget树中的位置的信息,而不是关于Widget本身的信息。
以主题为例,由于每个Widget都有自己的BuildContext ,这意味着如果你将多个主题分散在树中,那么获取一个Widget的主题可能会返回与另一个Widget不同的结果。在计数器应用示例程序中的主题特定情况下,或在其他of方法中,你将会获取到树中距离最近的该类型的父节点。

进阶
我们可以看到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; //1return 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; // 当前系统默认的语言LocaleLocale 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对应的Elementelement!.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 将不会响应各种事件,这可以保证在绘制过程中不会再触发新的重绘。scheduleWarmUpFrame方法的代码如下:
// flutter/packages/flutter/lib/src/scheduler/binding.dart
void scheduleWarmUpFrame() { if (_warmUpFrame || schedulerPhase != SchedulerPhase.idle) return; // 已发送帧渲染请求_warmUpFrame = true;Timeline.startSync('Warm-up frame');final bool hadScheduledFrame = _hasScheduledFrame;Timer.run(() { // 第1步,动画等相关逻辑handleBeginFrame(null); });Timer.run(() { // 第2步,立即渲染一帧(通常是首帧)handleDrawFrame();resetEpoch();_warmUpFrame = false; // 首帧渲染完成if (hadScheduledFrame) scheduleFrame();});lockEvents(() async { // 第3步,首帧渲染前不消费手势await endOfFrame;Timeline.finishSync();});
}
以上逻辑主要分为3步,但需要注意的是第3步是最先执行的,因为前两步是在Timer.run方法中启动的。handleBeginFrame方法将触发动画相关的逻辑,handleDrawFrame方法将触发3棵树的更新以及Render Tree的Layout和Paint等渲染逻辑。正常来说,这两个逻辑是Engine通过监听Vsync信号驱动的,这里之所以直接执行是为了保证首帧尽快渲染,因为不管Vsync信号何时到来,首帧都是必须渲染的。
总结

渲染管线
前面分析了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,中文翻译为 “渲染流水线” 或 “渲染管线”。
任何一个UI框架,无论是Web还是Android,都会有自己的渲染管道,渲染管道是UI框架的核心,负责处理用户的输入、生成UI描述、栅格化绘制指令、上屏最终数据等。Flutter也不例外。由于采用了自渲染的方式,Flutter的渲染管道是独立于平台的。以Android为例,Flutter只是通过Embedder获取了一个Surface或者Texture作为自己渲染管道的最终输出目标。

Flutter的渲染管道需要要通过来自系统Vsync信号的驱动,当需要更新UI的时候,Framework会通知Engine,Engine会等到下个Vsync信号到达的时候,会通知Framework进行animate, build,layout,paint,最后生成 layer 提交给Engine。Engine会把 layer 进行组合,生成纹理,最后通过Open GL接口提交数据给GPU, GPU经过处理后在显示器上面显示,如下图:

具体来说,Flutter的渲染管道分为以下7个步骤。
-
(1)用户输入(User Input):响应用户通过鼠标、键盘、触摸屏等设备产生的手势行为。
-
(2)动画(Animation):基于计时器(Timer)更新当前帧的数据。
-
(3)构建(Build):三棵树的创建、更新与销毁阶段,
StatelessWidget和State的build方法将在该阶段执行。 -
(4)布局(Layout):
Render Tree将在这个阶段完成每个节点的大小和位置的计算。 -
(5)绘制(Paint):
Render Tree遍历每个节点,生成Layer Tree,RenderObject的paint方法将在该阶段执行,生成一系列绘制指令。 -
(6)合成(Composition):处理
Layer Tree,生成一个Scene对象,作为栅格化 的输入。 -
(7)栅格化(Rasterize):将绘制指令处理为可供GPU上屏的原始数据。
下面我们以 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数据发送给GPUpipelineOwner.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(); // 请求新的framereturn;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内核源码剖析》
相关文章:
Flutter 笔记 | Flutter 核心原理(二)关键类和启动流程
Widget、Element、BuildContext 和 RenderObject Widget Widget关键类及其子类继承关系如图所示: 其中,Widget是Widget Tree所有节点的基类。Widget的子类主要分为3类: 第1类是RenderObjectWidget的子类,具体来说又分为SingleCh…...
Android:主题切换
一.概述 正在开发的应用做了一版新UI,原打算将新版UI按项目名做成资源包,再在build.gradle里productFlavors{ }多渠道打包实现 但被告知新旧两个项目共用一个分支,那就做成两个主题(Theme1/Theme2)来适配了 如果只是变更UI,做成…...
terminalworks ASP.NET Core PDF 浏览器-Crack
ASP.NET Core 的 PDF 查看器 terminalworks在 ASP.NET Core 网页或应用程序中添加可靠的 PDF 查看器的简单方法。 我们的 Web PDF 查看器基于经过验证和测试的 Mozilla PdfJS 解决方案,该解决方案在 Firefox 中用作默认 PDF 查看器。我们专门设计了我们的查看器&…...
Rust每日一练(Leetday0020) 最后单词的长度、螺旋矩阵II、排列序列
目录 58. 最后一个单词的长度 Length of Last Word 🌟 59. 螺旋矩阵 II Spiral Matrix II 🌟🌟 60. 排列序列 Permutation Sequence 🌟🌟🌟 🌟 每日一练刷题专栏 🌟 Rust每日…...
短视频矩阵源码如何做应用编程?
短视频矩阵源码, 短视频矩阵系统技术文档: 可以采用电子文档或者纸质文档的形式交付,具体取决于需求方的要求。电子文档可以通过电子邮件、远程指导交付云存储等方式进行传输、 短视频矩阵{seo}源码是指将抖音平台上的视频资源进行筛选、排…...
【运维知识进阶篇】Ansible实现一套完整LNMP架构
前面介绍了PlayBook怎么写服务部署,把服务部署上后,我们来用Ansible来部署项目,实现一套完整的LNMP架构。我们部署wordpress、wecenter、phpshe、phpmyadmin这四个项目。将其所有的剧本都写入lnmp.yml中,相关备份数据都放入root/a…...
Spring Boot 自动配置一篇概览
一、什么是自动配置 bean 自动配置类通过添加 AutoConfiguration 注解实现。 因为 AutoConfiguration 注解本身是以 Configuration 注解的,所以自动配置类可以算是一个标准的基于 Configuration 注解的类。 Conditional 注解可以用于声明自动配置启用条件&#x…...
深入理解设计原则之接口隔离原则(ISP)【软件架构设计】
系列文章目录 C高性能优化编程系列 深入理解软件架构设计系列 深入理解设计模式系列 高级C并发线程编程 LSP:接口隔离原则 系列文章目录1、接口隔离原则的定义和解读2、案例解读3、如何判断一个接口是否符合接口隔离原则?小结 1、接口隔离原则的定义和…...
IMX6ULL裸机篇之I2C实验主控代码说明二
一. I2C实验 I2C实验内容: 学习如何使用 I.MX6U 的 I2C 接口来驱动 AP3216C,读取 AP3216C 的传感器数据。 I2C读写数据时序图: I2C写数据时序图如下: I2C读数据时序图如下: 二. I2C主控读写时序 1. 读数据与写数…...
【计算机组成原理与体系结构】数据的表示与运算
目录 一、进位计数制 二、信息编码 三、定点数数据表示 四、校验码 五、定点数补码加减运算 六、标志位的生成 七、定点数的移位运算 八、定点数的乘除运算 九、浮点数的表示 十、浮点数的运算 一、进位计数制 整数部分: 二进制、八进制、十六进制 --…...
如何入门编程
随着信息技术的快速发展,编程已经成为一个越来越重要的技能。那么,我们该如何入门编程呢?欢迎大家积极讨论 一、自学编程需要注意什么? 对于我个人的理解,其实自学编程最重要的就是兴趣。你得培养编程兴趣。 所以在学…...
SQL中CONVERT转化日期函数的使用方法
SQL中CONVERT转化日期函数的使用方法 SQL中CONVERT函数最常用的是使用convert转化长日期为短日期,如果只要取yyyy-mm-dd格式时间, 就可以用convert(nvarchar(10),field,120) 120 是格式代码, nvarchar(10) 是指取出前10位字符. 例如 SELECT CONVERT(nvarchar(10),…...
SpringBoot2-核心技术(一)
SpringBoot2-核心技术(一) 了解SpringBoot配置文件的使用 文章目录 SpringBoot2-核心技术(一)了解SpringBoot配置文件的使用一、文件类型1. properties2. yaml 二、yaml的基本使用1. 基本语法2. 数据类型2.1 字面量 2.2 对象2.3 …...
mac host学习
参考: SSH中known_hosts文件作用和常见问题及解决方法 https://blog.csdn.net/luduoyuan/article/details/130070120在 Mac 上更改 DNS 设置 https://support.apple.com/zh-cn/guide/mac-help/mh14127/mac mac中有时候你输入的域名,但会跳转到与期望ip不…...
Java之~指定String日期时间,5分钟一截取时间
// 截取5分钟时间Testpublic void timeCutForDay() throws ParseException {String startTime "2023-03-28 09:16:03";String endTime "2023-03-31 23:59:59";SimpleDateFormat dateFormat new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");D…...
【chatGPT4结对编程】chatGPT4教我做图像分类
开始接触深度学习 大语言模型火了之后,我也想过是否要加入深度学习的行业当中来,一开始的想法就是AI大模型肯定会被各大厂垄断,我们作为普通应用型软件工程师直接调用api就完事,另外对自己的学历也自卑(刚刚够线的二本࿰…...
Different romantic
001 他暗恋上我们班上的一个女生。 He has a crush on a girl in our class. crush n. 迷恋 have a crush on (someone) 暗恋(某人) crush 也可以指“暗恋的对象”。例如,“他在大学曾经暗恋过两个人”,英语就是He had two crushe…...
learn C++ NO.7——C/C++内存管理
引言 现在是5月30日的正午,图书馆里空空的,也许是大家都在午休,也许是现在37摄氏度的气温。穿着球衣的我已经汗流浃背,今天热火战胜了凯尔特人,闯入决赛。以下克上的勇气也激励着我,在省内垫底的大学中&am…...
SDUT数据库原理——第十章作业(参考答案)
1. 简述使用检查点方法进行数据恢复的一般步骤。 答: (1)使用检查点方法进行数据恢复,首先从重新开始文件(见P302页图10.3)中找到最后一个检查点记录在日志文件中的地址,由该地址在日志文件中找到最后一个检查点记录。 (2)由该检查点记录得到检查点建立时刻所有正在…...
My Note of Diffusion Models
Diffusion Models Links: https://theaisummer.com/diffusion-models/ Markovian Hierachical VAE rvs: data: x 0 x_{0} x0,representation: x T x_{T} xT ( p ( x 0 , x 1 , ⋯ , x T ) , q ( x 1 , ⋯ , x T ∣ x 0 ) ) (p(x_0,x_1,\cdots,x_T),q(x_1,\cdots,x_{T…...
JavaSec-RCE
简介 RCE(Remote Code Execution),可以分为:命令注入(Command Injection)、代码注入(Code Injection) 代码注入 1.漏洞场景:Groovy代码注入 Groovy是一种基于JVM的动态语言,语法简洁,支持闭包、动态类型和Java互操作性,…...
【网络】每天掌握一个Linux命令 - iftop
在Linux系统中,iftop是网络管理的得力助手,能实时监控网络流量、连接情况等,帮助排查网络异常。接下来从多方面详细介绍它。 目录 【网络】每天掌握一个Linux命令 - iftop工具概述安装方式核心功能基础用法进阶操作实战案例面试题场景生产场景…...
工业安全零事故的智能守护者:一体化AI智能安防平台
前言: 通过AI视觉技术,为船厂提供全面的安全监控解决方案,涵盖交通违规检测、起重机轨道安全、非法入侵检测、盗窃防范、安全规范执行监控等多个方面,能够实现对应负责人反馈机制,并最终实现数据的统计报表。提升船厂…...
【WiFi帧结构】
文章目录 帧结构MAC头部管理帧 帧结构 Wi-Fi的帧分为三部分组成:MAC头部frame bodyFCS,其中MAC是固定格式的,frame body是可变长度。 MAC头部有frame control,duration,address1,address2,addre…...
centos 7 部署awstats 网站访问检测
一、基础环境准备(两种安装方式都要做) bash # 安装必要依赖 yum install -y httpd perl mod_perl perl-Time-HiRes perl-DateTime systemctl enable httpd # 设置 Apache 开机自启 systemctl start httpd # 启动 Apache二、安装 AWStats࿰…...
Java多线程实现之Callable接口深度解析
Java多线程实现之Callable接口深度解析 一、Callable接口概述1.1 接口定义1.2 与Runnable接口的对比1.3 Future接口与FutureTask类 二、Callable接口的基本使用方法2.1 传统方式实现Callable接口2.2 使用Lambda表达式简化Callable实现2.3 使用FutureTask类执行Callable任务 三、…...
Spring Boot面试题精选汇总
🤟致敬读者 🟩感谢阅读🟦笑口常开🟪生日快乐⬛早点睡觉 📘博主相关 🟧博主信息🟨博客首页🟫专栏推荐🟥活动信息 文章目录 Spring Boot面试题精选汇总⚙️ **一、核心概…...
ElasticSearch搜索引擎之倒排索引及其底层算法
文章目录 一、搜索引擎1、什么是搜索引擎?2、搜索引擎的分类3、常用的搜索引擎4、搜索引擎的特点二、倒排索引1、简介2、为什么倒排索引不用B+树1.创建时间长,文件大。2.其次,树深,IO次数可怕。3.索引可能会失效。4.精准度差。三. 倒排索引四、算法1、Term Index的算法2、 …...
深入解析C++中的extern关键字:跨文件共享变量与函数的终极指南
🚀 C extern 关键字深度解析:跨文件编程的终极指南 📅 更新时间:2025年6月5日 🏷️ 标签:C | extern关键字 | 多文件编程 | 链接与声明 | 现代C 文章目录 前言🔥一、extern 是什么?&…...
虚拟电厂发展三大趋势:市场化、技术主导、车网互联
市场化:从政策驱动到多元盈利 政策全面赋能 2025年4月,国家发改委、能源局发布《关于加快推进虚拟电厂发展的指导意见》,首次明确虚拟电厂为“独立市场主体”,提出硬性目标:2027年全国调节能力≥2000万千瓦࿰…...
