原来我一直在错误的使用 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),这里的BuildOwnerWidgetsBinding的初始化中完成实例化,负责管理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()


查看StatelessElementStatefulElement共同祖先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 == nullnewWidget != null
child == nullReturns null.Returns new [Element].
child != nullOld 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的runtimeTypekey,如果一致则说明子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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册