阅读 2255

Flutter 绘制探索 4 | 深入分析 setState 重建和更新 | 七日打卡

零:前言

1. 系列引言

可能说起 Flutter 绘制,大家第一反应就是用 CustomPaint 组件,自定义 CustomPainter 对象来画。Flutter 中所有可以看得到的组件,比如 Text、Image、Switch、Slider 等等,追其根源都是画出来的,但通过查看源码可以发现,Flutter 中绝大多数组件并不是使用 CustomPaint 组件来画的,其实 CustomPaint 组件是对框架底层绘制的一层封装。这个系列便是对 Flutter 绘制的探索,通过测试调试源码分析来给出一些在绘制时被忽略从未知晓的东西,而有些要点如果被忽略,就很可能出现问题。


2.关于 State#setState

我只是一把刀,英雄可以拿我除暴安良,坏蛋可以拿我屠戮无辜。我会因英雄的善举而被赞美,也会因坏蛋的恶行而被唾弃。然而,我无法决定自己的好坏,毕竟我只是一把刀,一个工具。我只能祈祷着被他人的善用,仅此而已。 这就是 State#setState ,一个触发刷新的工具,它的好与坏,不是取决于它的本身,而是使用它的人。

注:文章结尾有总结,注意查收,毕竟正文不是每个人都能看完的。


一、铁打的营盘流水的兵

1. 测试案例

这小结将通过一个测试来说明,在 Flutter 中的刷新时,什么在变,什么不在变。这对理解 Flutter 来说至关重要。此处用来一个最精简的 StatefulWidget 进行测试,效果如下:每 3 秒依次变色为 红黄蓝绿

void main() => runApp(ColorChangeWidget());

class ColorChangeWidget extends StatefulWidget {
  @override
  _ColorChangeWidgetState createState() => _ColorChangeWidgetState();
}

class _ColorChangeWidgetState extends State<ColorChangeWidget> {
  final List<Color> colors = [
    Colors.red, Colors.yellow,
    Colors.blue, Colors.green ];

  Timer _timer;
  int index = 0;
  
  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(Duration(seconds: 5), _update);
  }

  void _update(timer) {
    setState(() {
      index = (index + 1) % colors.length;
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: ShapePainter(color: colors[index]),
    );
  }
}
复制代码

这里自定义一个 StatefulWidget,使用 Timer.periodic 创建一个定时的计时器,每 3 秒触发一次,修改激活的索引,并执行 _ColorChangeWidgetState#setState 来重构界面。绘制还是由 ShapePainter 画个圈,使用 CustomPaint 进行显示。

class ShapePainter extends CustomPainter {
  final Color color;
  ShapePainter({this.color});
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()..color = color;
    canvas.drawCircle(Offset(100, 100), 50, paint);
  }
  
  @override
  bool shouldRepaint(covariant ShapePainter oldDelegate) {
    return oldDelegate.color != color;
  }
}
复制代码

2.案例调试测试

现在只在 ShapePainter#paint 方法上添加断点, 下面是两次 paint 时的情况。我们可以发现一个非常重要的地方,那就是 State#setstate 虽然会重建当前 build 方法下的节点,但是 RenderObject 对象是不会重建的,如下 RenderCustomPaint 的内存地址一直都是 #1dbcd。你可以放行断点,让颜色多变化几次,你会发现渲染对象的地址是一直保持不变的。

但有一个对象一直在变,那就是 ShapePainter 对象。从 _ColorChangeWidgetState#build 中也可以看到画板对象一直变化的原因,因为 State#setState 会触发 State#build ,而在 buildShapePainter 是被重新实例化的。

---->[_ColorChangeWidgetState#build]----
@override
Widget build(BuildContext context) {
  return CustomPaint(
    painter: ShapePainter(color: colors[index]),
  );
}
复制代码

你也许会想,为什么不将 ShapePainter 作为成员变量,这样就不需要每次 build 都创建了。通过 Flutter 源码中对 CustomPainter 的使用可以知道,对应静态的绘制,画板类中的属性都是定义为 final ,也就是常量,是不允许修改属性的。这样即使 ShapePainter 为成员变量,也无法修改信息。此时 CustomPainter 就像Widget 一样只是一种配置的描述,是轻量的。

在第一篇也说过,对于有 滑动动画 需求的绘制,重建触发的频率非常大,此时即使对象是 轻量的,也会在短时间内创建大量对象,这样不是很好。这时可以使用 repaint 属性来控制画板的刷新,做到在画板对象保存不变的情况下,刷新画板,其原理也在第三篇说过了。 所以对应静态的绘制而言外界的 State#setState,会让 WidgetCustomPainter 这样的描述性信息对象重新创建。而真正承担绘制、布局的 RenderObject 对象还是同一个对象,这便是 铁打的营盘流水的兵


二、State#setState 做了什么

1. setState 方法调试分析

setState 是 State 类中的成员方法,其中传入一个回调方法。经过断言后,会执行回调方法,并执行 _element.markNeedsBuild() 。可以看到 setState 方法主要就是执行这个方法,那 _enement 是什么呢?


每个 State 类都会持有 StatefulElement 和 StatefulWidget 对象,这里也就是执行 State 持有的这个ElementmarkNeedsBuild() 方法。

可以从变量面板看出,当前 _ColorChangeWidgetState 持有的 Widget 是 ColorChangeWidget,持有的 Element 是 StatefulElement 。现在也就是即将调用这个 Element 对象的 markNeedsBuild() 方法。


下一步就会进入 Element.markNeedsBuild,也就是 Element 类中。在两个小判断之后,该元素的 _dirty 属性被置为 true,也就是元素标脏。然后执行 owner.scheduleBuildFor(this),其中 owner 对象是 Element 的成员,其类型为 BuildOwner,注意方法的入参是 this,也就是该元素自身。


下一步将进入 BuildOwner.scheduleBuildFor ,如果 element_inDirtyList 为 true,会直接返回。一般只有被加入 脏表集合 后才会置为 true , 如下 2590 行。当条件满足,会执行 onBuildScheduled 方法。


此时方法会进入 WidgetsBinding._handleBuildScheduled。也就是说 onBuildScheduledBuildOwner 中的一个方法成员,在某个时刻被赋值成了 WidgetsBinding._handleBuildScheduled , 所以才会跑到这里。这里只是调用了一下 ensureVisualUpdate


SchedulerBinding.ensureVisualUpdate 方法中会通过 scheduleFrame 来调度一个新帧。

在该方法里通过 window.scheduleFrame() 来请求新的帧,

Window#scheduleFrame 是一个 native 方法,通过注释可以知道,该方法会在下一次适当的时机调用onBeginFrameonDrawFrame 回调函数。


之后方法进行完毕,一波退栈,回到了 BuildOwner.scheduleBuildFor。BuildOwner中有一个 _dirtyElements 列表用于存储脏元素。然后当前元素就被收录进去,并将 _inDirtyList 置为 true。setState 到这里就退栈了。

所以 State#setState 主要就做两件事:

1、通过 onBuildScheduled  触发帧的调度 
2、将当前 State 持有的 Element 对象加入 BuildOwner 中的脏表集合
复制代码

2.元素的 rebuild

虽然 setState 方法结束了,但它的余威还在。在触发帧的调度后,会触发帧的重新绘制,被表脏的元素也会触发 rebuild。还记得 BuildOwner 中维护的 _dirtyElements 脏表集合吧,BuildOwner 是用于负责管理和构建元素的类,每个帧的重绘都会走到这个方法中。现在在 BuildOwner.buildScope 打上断点,可以看到绘制帧的方法入栈情况。


一开始会判断 callback 是否为 null,且 _dirtyElements 是否为空,如果都满足的话,就说明不需要重建,直接返回。我们知道刚才由于 State#setState 方法,有一个元素被装进脏表中了,所有会继续执行。

这里会先通过 sort 对脏元素列表进行排序。


在这里会遍历 _dirtyElements 执行其中 elementrebuild 方法,那么好戏即将开始。


我们在任何时候都不能忘本,要时刻清楚 this 是什么,这是浩瀚源码之海中最亮的明灯。执行 rebuild 方法的,是之前被加入脏表的那个 StatefulElement,接下来会进入 Element.rebuild。因为 StatefulElement 中重写 rebuild ,使用才会到父类的方法中。可以看到 rebuild 方法中只是做了一断言而已,执行了 performRebuild


然后进入到的是 StatefulElement.performRebuild,很明显,是由于 StatefulElement 重写了该方法。下面有一个比较重要的点:如果 _didChangeDependencies 为 true ,那么 _state 会触发 didChangeDependencies() 回调方法。


可以看出 StatefulElement 会持有 State 对象,而 State 对象又会持有 StatefulElement,从下面的图片可以看出当前对象类型,StatefulElement_ColorChangeWidgetState 是互相持有的关系。


这里 _didChangeDependencies 为 false,然后会执行 super.performRebuild()。由于 StatefulElement 的父类是 ComponentElement,所以入栈方法如下:


继续向下,会发现有一个局部变量 built 会通过 build() 方法初始化。接下来,就是见证奇迹的时刻。


继续前进,这个 build 方法的实现是_state.build(this) ,这时你应该会恍然大悟,这句代码意味着什么。下一刻将会发生什么,这个 this 当前元素将要去往哪里。


这里的 _state 成员,我们已经知道了是 _ColorChangeWidgetState ,那么这个 build 方法,也就是我们写的构建组件。第一次,源码和我们写的东西出现了交集,而回调的 BuildContext 对象,就是那个 Element。如下,在 build 方法里 CustomPaintShapePainter 都被重新实例化了。它们已经不再是曾经的它们,它们如同草屑一般被抛弃,新的对象携带者新的配置信息,加入到了这一轮的构建。ShapePainter 的颜色此时会随着 index 变化而改变。


然后方法弹栈后,built 对象被赋值为刚才创建的 CustomPaint 对象,其持有的 ShapePainter 是下一个颜色,这样 built 对象成为了携带着新配置信息的打工人,开始了工作。


然后来到一个非常核心的方法 Element#updateChild。在进入这个方法之前,先梳理一下元素树的层级关系。目前元素树上只有 3 个元素,最顶层的是框架内部创建的 RenderObjectToWidgetElement ,第二个就是当前的 this----StatefulElement ,第三个是 CustomPaint 组件创建的 SingleChildRenderObjectElement 。如果对此有什么疑惑,可见第二篇。这里就是通过 built 这个新的Widget 对 _child 进行更新,这个 _child 就是第三节点 SingleChildRenderObjectElement


下面进入 Element.updateChild ,注意此时变量区的信息。

[1]. newWidget 也就是新创建的 含有新配置信息 的打工人。
[2]. this 是第二元素节点,也就是 updateChild 方法的调用者,一个 StatefulElement 对象
[3]. child 就是第三元素节点,那个待更新的孩子 SingleChildRenderObjectElement
[4]. 这里的返回值是为了更新 this 节点的 _child 属性,也就是更新 第三元素节点
复制代码

当 newWidget 为 null 时,会返回 null,且 child 不为 null 时,会被从树上移除。这里都非 null 会继续向下,声明一个 newChild 的局部变量,这里 child 非空。


继续向下,就是新旧打工人的比较,child.widget 持有的是之前的 CustomPaintnewWidget 是新的,所以这个条件不满足。从这也可以看出,如果新旧 Widget 对象不变的话,会有优化,直接使用旧的孩子。


由于新旧 Widget 不是同一对象,就会走下面分支,判断 Widget 是否可以更新。可更新的条件是:新旧组件的运行时类型和 key 一致 ,这里是满足的,继续向下。


然后会执行 child.update(newWidget) ,使用新的配置信息来更新 child ,也就是 第三元素节点


然后进入 SingleChildRenderObjectElement.update ,会执行 super 的 update 方法。


进入 RenderObjectElement.update 后,依然会执行 super.update,到达顶层的 Element#update,这里的操作仅是将 _widget 成员赋值为 newWidget


3. RenderObject 的更新

然后 Element#update 出栈,回到 RenderObjectElement.update 方法,在这里执行了一个非常重要的方法 widget.updateRenderObject(this, renderObject)。这是希望你已经理解了前面的三篇文章,然后向下看,效果会更好。


然后会进入 CustomPaint.updateRenderObject 方法,对传入的 renderObject 进行属性的重设。这时你就可以发现,这个 renderObject 是被传入来的,所以该渲染对象并未被重新创建,这时对该对象的属性进行了设置。所以现在明白第一小结 铁打的营盘流水的兵 是什么意思了吧,配置信息相关的对象非常轻量,可以重新创建,而 RenderObject 是绘制的阵营,只要对配置信息进行重新设置即可。


到这里,你还记得在 RenderCustomPaintset painter 会怎么样吗?第三篇有说。会触发 _didUpdatePainter 方法。

然后根据 shouldRepaint 来决定在画板重设时,是否需要触发重绘。所以 shouldRepaint 只有在外界迫 使 RenderCustomPaint 重新设置 painter 时才会触发,其中最常见的就是外界的 State#setState。所以 shouldRepaint 把守的是这道门。


在两个画板不同时,通过 markNeedsPaint 将自己加入 PipelineOwner 的待绘制列表,等待重绘。


RenderObject 更新完后,方法依次出栈,会到 RenderObjectElement.update ,将 _dirty 置为false,便出栈。


然后 SingleChildRenderObjectElement 依然会更新它的孩子,由于这里它的 child 是 null ,方法执行完依然是 null。


第三元素节点更新后,方法退回到 ComponentElement.performRebuild ,此时的 _child 所持有 RenderObject 对象已经使用新的配置更新完毕,并加入了待重新渲染的列表。也就是说,使用 setState 进行更新,只是轻量级的配置信息创新创建,而 ElementRenderObjectState 这样的对象不会重新创建,只是根据配置信息进行了更新。


更新完毕,退栈到 BuildOwner.buildScope进行首尾工作,清空脏表。


接下来 BuildOwner.buildScope 出栈 RendererBinding.drawFrame 入栈,之后的事就是绘制了。这个方法应该已经非常熟悉了。在第三篇有详细说 pipelineOwner.flushPaint() 方法,这里就不再说明了。


最终,会触发 ShapePainter#paint 进行绘制。这就是在 setState 时进行的 Element 重新构建RenderObject 的更新。我们应该已经了解到,一般情况下使用 setState 不会让 ElementRenderObject 重新创建,而是基于新的 Widget 配置信息进行更新。这差不多就是四两拨千斤吧。


三、小结

1.State#setState 真的那么可怕吗?

从 Flutter 最初的时代,State#setState 如同神迹一般的存在,想刷新就用 setState 。以至于 State#setState 被滥用,各种时机的刷新满天飞。当认识到 ValueListenableBuilderFutureBuilderStreamBuilderAnimatedBuilder 这些组件的局部刷新,或者 ProviderBloc 这样的状态管理提高的局部刷新组件,似乎让 State#setState 成为了闲谈中被口诛笔伐的对象,会发出这样的言论,这是很片面的。我只想说,和文章开头一样,State#setState 只是一个工具,工具没有好与坏。

通过上面的代码可以发现 State#setState 的作用是将持有的 Element 加入待构建的脏表,并触发帧的调度来重新构建和绘制。所以 State#setState 的好与坏取决于 Element 的层级,如果有人非要在高层级使用 State#setState 来刷新,说 State#setState 不好,就相当于残害无辜后说这把刀是恶刀一样。


2. 局部刷新,也只是 setState 的封装

ValueListenableBuilder 组件是监听对象变化使用 setState 进行重新构建的。


FutureBuilder 组件根据异步任务的状态,使用 setState 进行重新构建的。


StreamBuilder 组件根据 Stream 的状态,使用 setState 进行重新构建的。


AnimatedBuilder 组件也是监听动画器,使用 setState 进行重新构建的。


就算是状态管理 BlocBlocBuilder 也是依赖于 setState 进行重新构建的。


Provider 中,对刷新进行了一定的封装,但还是最终还是离不开 element#markNeedsBuild

所以说无论什么局部刷新,内部的原理都和 State#setState 是一样的。基本上都是对 setState 的一层封装。我们不能因为看不到 State#setState 的存在,就否定它的价值。就像一边让人家在底层干活,一边说着别人的坏话一样。对应 setState 我们要注意的是它刷新元素的层级,而不是否定它。


3.Custompainter # shouldRepaint 把守了什么?

现在来终结一下 Custompainter#shouldRepaint 只是在当 RenderCustomPaint 设置画板属性的时候才会被回调。 RenderCustomPaint 设置画板属性的场景在于:其对应的 RenderObjectElement 触发 update 时,由 widget#updateRenderObject 方法进行属性设置,注意只是属性的设置,而非对象的重建。

---->[CustomPaint#updateRenderObject]----
@override
void updateRenderObject(BuildContext context, RenderCustomPaint renderObject) {
  renderObject
    ..painter = painter
    ..foregroundPainter = foregroundPainter
    ..preferredSize = size
    ..isComplex = isComplex
    ..willChange = willChange;
}
复制代码

RenderObjectElement 触发 update 触发基本上是由于外界执行 setState 方法。所以 shouldRepaint 的作用也是有局限性的。下一篇将一起探索 shouldRepaint 监管不到的重绘场景,以及对应的解决方案。


文章分类
阅读
文章标签