原来我一直在错误的使用 setState()?
导语
任何前端系统与用户关系最密切的部分就是UI。一个按钮,一个标签,都是通过对应的UI元素展示与交互。初学时,我们往往只关注如何使用。但如果只知道如何使用,遇到问题我们很难找到解决的办法和思路,也无法针对一些特定场景进行优化。本期针对Flutter的UI系统和大家一起进阶学习:
1、原来我一直在错误的使用 setState()?
2、面试必问:说说Widget和State的生命周期
3、Flutter的布局约束原理
4、15个例子解析Flutter布局过程
读完本文你将收获:Flutter的渲染机制以及setState()背后的原理
引言
初学Flutter的时候,当需要更新页面数据时,我们通常会想到调用setState()。但很多博客以及官方文章并不建议我们在页面的节点使用setState(),因为这样会带来不必要的开销(仅针对页面节点,当然Flutter的Widget刷新一定离不开setState()),很多状态管理方案也是为了达到所谓的“局部刷新”。到这我们不仅要思考为什么使用setState()能刷新页面,又为何可能会带来额外的损耗?这个函数背后做了什么逻辑?这篇文章和大家一一揭晓。
一、为什么setState()能刷新页面
1、setState()
我们的demo从一个最简单的计数器开始
在页面中点击底部的➕号,本地变量加一,之后调用了当前页面的setState()
,页面重新构建,显示的数据增加。从现象推断,整个流程必然会经过setState()
-···················->当前State的build()
-················->页面绘制-············->屏幕刷新。
那么下面我们看看setState()到底做了什么?
State#setState(VoidCallback fn)
@protected
void setState(VoidCallback fn) {
final dynamic result = fn() as dynamic;
_element.markNeedsBuild();
}
在去掉所有的断言之后,其实setState只做了两件事儿
1、调用我们传入的VoidCallback fn
2、调用_element.markNeedsBuild()
2、element.markNeedsBuild()
Flutter开发中我们一般和Widget打交道,但Widget上有这样一个注释。
Describes the configuration for an [Element].
abstract class Widget extends DiagnosticableTree {
final Key key;
Element createElement();
String toStringShort() {
return key == null ? '$runtimeType' : '$runtimeType-$key';
}
Widget只是用于描述Element
的一个配置文件,实际在Framework层管理页面的构建,渲染等,都是通过Element
完成,Element
由Widget创建,并且持有Widget对象,每一种Widget都会对应的一种Element
。
在上面的demo中,我们在HomePageState
调用了setState(),这里的Element有HomePage对象创建。HomePage(Widget) - HomePageState(State) - HomePageElement(StatefulElement) 三者一一对应。
Element#markNeedsBuild()
/// The object that manages the lifecycle of this element.
/// 负责管理所有element的构建以及生命周期
@override
BuildOwner get owner => _owner;
void markNeedsBuild() {
//将自己标记为脏
_dirty = true;
owner.scheduleBuildFor(this);
}
调用了BuildOwner.scheduleBuildFor(element)
,这里的BuildOwner
在WidgetsBinding
的初始化中完成实例化,负责管理widget框架,每个Element
对象在mount
到element树中之后都会从父节点获得它的引用。
WidgetsBinding#initInstances()
void initInstances() {
super.initInstances();
_instance = this;
_buildOwner = BuildOwner();
buildOwner.onBuildScheduled = _handleBuildScheduled;
/······/
}
BuildOwner#scheduleBuildFor(Element element)
void scheduleBuildFor(Element element) {
//添加到_dirtyElements集合中
_dirtyElements.add(element);
element._inDirtyList = true;
}
最后将自己添加到BuildOwner
中维护的一个脏element集合。
总结:1、Element: 持有Widget,存放上下文信息,RenderObjectElement 额外持有 RenderObject。通过它来遍历视图树,支撑UI结构。
2、setState()过程其实只是将当前对应的Element标记为脏(demo中对应HomePageState),并且添加到
_dirtyElements
合中。
3、Flutter渲染机制
上面的过程看起来没做任何渲染相关的事儿,那么页面是如何重新绘制?关键点就在于Flutter的渲染机制
开始FrameWork层会通知Engine表示自己可以进行渲染了,在下一个Vsync信号到来之时,Engine层会通过Windows.onDrawFrame
回调Framework进行整个页面的构建与绘制。(这里我想为什么要先由Framework发起通知,而不是直接由Vsync驱动。如果一个页面非常卡顿,恰好每一帧绘制的时间大于一个Vsync周期,这样每帧都不能在一个Vsync的时间段内完成绘制。而先由framework保证上完成构建与绘制后,发起通知在下一个Vsync信号再绘制则可以避免这样的情况)。每次收到渲染页面的通知后,Engine调用Windows.onDrawFrame
最终交给_handleDrawFrame()
方法进行处理。
@protected
void ensureFrameCallbacksRegistered() {
//构建帧前的处理,主要是进行动画相关的计算
window.onBeginFrame ??= _handleBeginFrame;
//Windows.onDrawFrame交给_handleDrawFrame进行处理
window.onDrawFrame ??= _handleDrawFrame;
}
复制代码
SchedulerBinding#handleDrawFrame()
void handleDrawFrame() {
try {
// PERSISTENT FRAME CALLBACKS
// 关键回调
for (FrameCallback callback in _persistentCallbacks)
_invokeFrameCallback(callback, _currentFrameTimeStamp);
// POST-FRAME CALLBACKS
final List<FrameCallback> localPostFrameCallbacks =
List<FrameCallback>.from(_postFrameCallbacks);
_postFrameCallbacks.clear();
for (FrameCallback callback in localPostFrameCallbacks)
_invokeFrameCallback(callback, _currentFrameTimeStamp);
} finally {
/·····························/
}
}
在Flutter AnimationController回调原理一期中我们提到过,在Flutter的SchedulerBinding
中维护了这样三个队列
- Transient callbacks,由系统的[Window.onBeginFrame]回调,用于同步应用程序的行为 到系统的展示。例如,[Ticker]s和[AnimationController]s触发器来自与它。
- Persistent callbacks 由系统的[Window.onDrawFrame]方法触发回调。例如,框架层使用他来驱动渲染管道进行build, layout,paint
- Post-frame callbacks在下一帧绘制前回调,主要做一些清理和准备工作 Non-rendering tasks 非渲染的任务,可以通过此回调获取一帧的渲染时间进行帧率相关的性能监控。
SchedulerBinding.handleDrawFrame()
中对_persistentCallbacks
和_postFrameCallbacks
集合进行了回调。根据上面的描述可知,_persistentCallbacks
中是一些固定流程的回调,例如build,layout,paint。跟踪这个_persistentCallbacks
这个集合,发现在RendererBinding.initInstances()
初始化中调用了addPersistentFrameCallback(_handlePersistentFrameCallback)
方法。这个方法只有一行调用就是drawFrame()
。
总结:
SchedulerBinding
中维护了这样三个队列TransientCallbacks(动画处理),PersistentCallbacks(页面构建渲染),PostframeCallbacks(每帧绘制完成后),并在合适的时机对其进行回调。- 当收到Engine的渲染通知之后通过
Windows.onDrawFrame
方法回调到Framework层调用handleDrawFrame
handleDrawFrame
回调PersistentCallbacks(页面构建渲染),最终调用drawFrame()
4、drawFrame()
查看drawFrame()
方法一般会直接点击到RendererBinding
中
RendererBinding#drawFrame()
void drawFrame() {
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}
从这几个方法名能大致看出,这里调用了布局,绘制,渲染帧的。而且看类名,这是负责渲染的Binding,并没有调用Widget的构建。这是因为WidgetsBinding
是onRendererBinding
的(理解为继承),其中重写了drawFrame()
,实际上调用的应该是WidgetsBinding.drawFrame()
。
WidgetsBinding#drawFrame()
@override
void drawFrame() {
try {
if (renderViewElement != null)
// buildOwner就是前面提到的负责管理widgetbuild的对象
// 这里的renderViewElement是整个UI树的根节点
buildOwner.buildScope(renderViewElement);
super.drawFrame();
//将不再活跃的节点从UI树中移除
buildOwner.finalizeTree();
} finally {
/·················/
}
}
在super.drawFrame()
之前,先调用 buildOwner.buildScope(renderViewElement)
。
BuildOwner#buildScope(Element context, [ VoidCallback callback ])
void buildScope(Element context, [ VoidCallback callback ]) {
if (callback == null && _dirtyElements.isEmpty)
return;
try {
_scheduledFlushDirtyElements = true;
_dirtyElementsNeedsResorting = false;
_dirtyElements.sort(Element._sort);
_dirtyElementsNeedsResorting = false;
int dirtyCount = _dirtyElements.length;
int index = 0;
while (index < dirtyCount) {
try {
///关键在这
_dirtyElements[index].rebuild();
} catch (e, stack) {
/···············/
}
}
} finally {
for (Element element in _dirtyElements) {
element._inDirtyList = false;
}
_dirtyElements.clear();
}
}
前面在setState()
之后,将homePageState添加到_dirtyElements
里面。而这个方法会对集合内的每一个对象调用rebuild()
。rebuild()
这个方法最终走到performRebuild()
,这是一个Element中的一个抽象方法。
二、为什么高位置的setState ()会消耗性能
1、performRebuild()
查看StatelessElement
和StatefulElement
共同祖先CompantElement
中的实现
CompantElement#performRebuild()
void performRebuild() {
Widget built;
try {
built = build();
} catch (e, stack) {
built = ErrorWidget.builder();
}
try {
_child = updateChild(_child, built, slot);
} catch (e, stack) {
built = ErrorWidget.builder();
_child = updateChild(null, built, slot);
}
}
这个方法直接调用子类的build方法返回了一个Widget,对应调用前面的HomePageState()中的build方法。
将这个新build()出来的widget和之前挂载在Element树上的_child(Element类型)作为参数,传入updateChild(_child, built, slot)
中。setState()的核心逻辑就在 updateChild(_child, built, slot)
。
2、updateChild(_child, built, slot)
StatefulElement#updateChild(Element child, Widget newWidget, dynamic newSlot)
@protected
Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
if (newWidget == null) {
if (child != null)
//child == null && newWidget == null
deactivateChild(child);
//child != null && newWidget == null
return null;
}
if (child != null) {
if (child.widget == newWidget) {
//child != null && newWidget == child.widget
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
return child;
}
if (Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot)
updateSlotForChild(child, newSlot);
//child != null && Widget.canUpdate(child.widget, newWidget)
child.update(newWidget);
return child;
}
deactivateChild(child);
}
// child != null && !Widget.canUpdate(child.widget, newWidget)
return inflateWidget(newWidget, newSlot);
}
这个方法上官方提供了这样的注释:
newWidget == null | newWidget != null | |
---|---|---|
child == null | Returns null. | Returns new [Element]. |
child != null | Old child is removed, returns null. | Old child updated if possible, returns child or new [Element]. |
总的来说,根据之前挂载在Element树上的_child
以及再次调用build()出来的newWidget
对象,共有四种情况
- 如果之前的位置child为null
- A、如果newWidget为null的话,说明这个位置始终没有子节点,直接返回null即可。
- B、如果newWidget不为null,说明这个位置新增加了子节点调用inflateWidget(newWidget, newSlot)生成一个新的Element返回
- 如果之前的child不为null
- C、如果newWidget为null的话,说明这个位置需要移除以前的节点,调用
deactivateChild(child)
移除并且返回null- D、如果newWidget不为null的话,先调用
Widget.canUpdate(child.widget, newWidget)
对比是否能更新。这个方法会对比两个Widget的runtimeType
和key
,如果一致则说明子Widget没有改变,只是需要根据newWidget(配置清单)更新下当前节点的数据child.update(newWidget)
;如果不一致说明这个位置发生变化,则deactivateChild(child)
后返回inflateWidget(newWidget, newSlot)
而在demo中,观察代码我们可以知道
在homePageState中调用setState()后,child和newWidget都不为空都是Scaffold类型,并且由于我们没有显示的指定key,所以会走child.update(newWidget)
方法**(注意这里的child已经变成Scaffold)**。
3、递归更新
update(covariant Widget newWidget)
是一个抽象方法,不同element有不同实现,以StatulElement为例
void update(StatefulWidget newWidget) {
super.update(newWidget);
assert(widget == newWidget);
final StatefulWidget oldWidget = _state._widget;
// Notice that we mark ourselves as dirty before calling didUpdateWidget to
// let authors call setState from within didUpdateWidget without triggering
// asserts.
_dirty = true;
_state._widget = widget;
try {
final dynamic debugCheckForReturnedFuture = _state.didUpdateWidget(oldWidget) as dynamic;
} finally {
_debugSetAllowIgnoredCallsToMarkNeedsBuild(false);
}
rebuild();
}
这个方法先回调用_state.didUpdateWidget
我们可以在State中重写这个方法,走到最后发现最终再次调用了rebuild()
。但这里需要注意这次调用rebuild()
的已经不是HomePageState了,而是他的第一个子节点Scaffold。所以整个过程又会再次走到performRebuild()
,又在再次调用updateChild(_child, built, slot)
更新子节点。不断的递归直到页面的最子一级节点。如图:
build()过程虽然只是调用一个组件的构造方法,不涉及对Element树的挂载操作。但因为我们一个组件往往是N多个Widget的嵌套组合,每个都遍历一遍开销算下来并不小(感兴趣可以数数Scaffold有多少层嵌套)。
回到我们的demo中,其实我们的诉求只是点击+号改变以前显示的数据。
但直接在页面节点调用setState()
将会重新调用所有Widget(包括他们中的各种嵌套)的build()
方法,如果我们的需求是一个较为复杂的页面,这样带来的开销消耗可想而知。
而要想解决这个问题可以参考告别setState()! 优雅的UI与Model绑定 Flutter DataBus使用~
总结
当我们在一个高节点调用setState()
的时候会构建再次build所有的Widget,虽然不一定挂载到Element树中,但是平时我们使用的Widget中往往嵌套多个其他类型的Widget,每个build()方法走下来最终也会带来不小的开销,因此通过各种状态管理方案,Stream等方式,只做局部刷新,是我们日常开发中应该养成的良好习惯。
最后
本期我们分析了setState()
过程,重点分析了递归更新的过程。正如安卓Activity或者Fragment的生命周期,Flutter中Widget和State同样也提供了对应的回调,如initState()
,build()
。这些方法背后是谁在调用,他们的调用时序是如何?Element的生命周期是如何调用的?将会在下一期和大家一一分析~
作者:Nayuta
链接:https://juejin.cn/post/6905996819445055495
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。