原来我一直在错误的使用 setState()?

8,862 阅读9分钟

学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,大概化二十篇左右文章分析,欢迎关注,共同进步。[Flutter framework]

导语

任何前端系统与用户关系最密切的部分就是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的生命周期是如何调用的?将会在下一期和大家一一分析~

最后 求赞求关注 QAQ