ProxyWidget和Element更新的正确方式详解

正文

Flutter的众多Widget当中,有作用于渲染的RenderObjectWidget、聚焦于功能整合的StatefulWidget。但是,还有一个大类,ProxyWidget也同样值得我们关注。

与其相关的有两个大类:

  • InhertedWidget
  • ParentDataWidget(代表:Positioned、Expanded)

这两个Widget,无非都是数据的向下传递,其一InheritedWidget更多的是业务数据,比如用户的ID、购物车的条目等等,而ParentDataWidget一般都是视图的数据,Stack需要使用parentData参数中的长宽、偏移量来完成对子Widget的定位。

所以,我们可以根据ProxyWidget的子类,向上预先给ProxyWidget扣一个数据共享的「帽子」。

1. ProxyWidget和ProxyElement的主要功能

ProxyWidget本身是抽象的,需要我们重写它的createElement()方法:

class CustomProxyWidget extends ProxyWidget {
 const CustomProxyWidget({required Widget child}) : super(child: child);
 @override
 Element createElement() => CustomProxyElement(this);
}

而ProxyElement则要重写notifyClients方法。

class CustomProxyElement extends ProxyElement {
 CustomProxyElement(ProxyWidget widget) : super(widget);
 @override
 void notifyClients(covariant ProxyWidget oldWidget) {
 //......
 }
}

整个ProxyElement的关键代码,就notifyClients这个函数的实现,它传入了一个老的、支持协变的ProxyWidget进来,这意味着传进来的应该是一个老的CustomProxyWidget的实例,这意味着我们在notifyClients中,可以同时拿到老的CustomProxyWidget实例和当前CustomProxyWidget实例的引用,分别是oldWidgetthis.widget

一新一旧,不难看出ProxyWidget的notifyClients调用,应该是要去做一些新旧Widget的数据比较而存在的

比如,我们可以这样重写它:

@override
void notifyClients(covariant ProxyWidget oldWidget) {
 if((oldWidget as CustomProxyWidget).data != (widget as CustomProxyWidget).data){
 // 通知所有订阅者,数据变动了
 _clients.foreach((e)=>e.notify());
 }
}

我们可以根据data属性(data是CustomProxyWidget新增的一个int类型的字段)的变化,来决定是否需要通知订阅者的Element是否去重新绘制子Widget,一旦data发生了变化,那么就去遍历_clients中的数据,并调用e.notify操作监听者重新绘制视图。

这让我们不禁和InheritedWidgetupdateShouldNotify联系起来,简单分析一下updateShouldNotify的调用链条:

InhertiedElement#update -> updateShouldNotify() 判断是否需要更新数据
InhertiedElement#update -> callsuper 即调用ProxyElement的update方法
ProxyElement#update -> notifyClients();

显然,InheritedWidget将notifyClients做了一个封装updateShouldNotify,并把这个封装放在Widget层,而不是直接让开发者去重写notifyClients这一层,这么做的原因其实和BuildContext存在的意义是一样的,让上层应用开发者只关注Widget,而更少地去感知Element的存在

总而言之,notifyClients存在的作用和意义,就是通知订阅它的子Widget,以实现子Widget的更新,我们也能稍稍瞥见一些ProxyWidget和ProxyElement的作用,大体上都是和数据传输和共享相关的。

2. InheritedWidget

基于观察者模式的InheritedWidget,它的使用我们就不做过多的叙述了,整体上而言,就三步走:

  • 注册:利用BuildContext注册监听
  • 通过BuildContext获取数据
  • 通知:改变促进监听者的数据重绘

这是一个非常典型的观察者模式的使用步骤,只不过InheritedWidget为我们做了一些封装,「注册」、「通知」操作变得更加地“隐蔽”了。

2.1 注册

使用InheritedWidget时,我们并没有手动地调用addListener、addObserver这类的方法,去主动添加监听,这一切都是无感的。我们一般通过如下方法获取到InheritedWidget中的数据。

context.dependOnInheritedWidgetOfExactType<ShareDataWidget>();

这一行代码已经包括两个步骤了:注册监听和获取数据。

InheritedElement当中,有一个特殊的结构,它存储了我们上面通过context调用时的context,这样来实现注册的监听,并且,在注册完成之后,会将所需要的数据返回给调用者,这样一来,监听注册、数据的获取这一个操作就合二为一了。

final Map<Element, Object?> _dependents = HashMap<Element, Object?>();

2.2 通知

对于StatefulWidget的重绘,我们一定会想到一个方法:markNeedsBuild(),所以,我们就顺着上述的调用,查找是否有相关的调用,我们可以看看属于InheritedElement的notifyClients的调用链:

InheritedElement# notifyClients
 InheritedElement# notifyDependent(oldWidget, dependent);
 dependent#didChangeDependencies();

一路从notifyClients调用到_dependents中的某个dependentdidChangeDependencies方法,这就是通知的整个流程,InheritedWidget通过这样的调用,通知所有挂载着的监听者,即其他需要InheritedWidget数据的Widget的BuildContext,并调用BuildContext的didChangeDependencies,它的实现如下:

@mustCallSuper
void didChangeDependencies() {
 ……
 markNeedsBuild();
}

至此,InheritedWidget是如何通知到子Widget进行更新的整个链路已经是非常清晰了。

由于didChangedDepenedencies()的存在,只有添加了依赖的结点才会因为数据的更新而造成节点的rebuild,而不会像StatefulWidget一样,对整棵子树做一次完全的rebuild,这是整个ProxyWidget/ProxyElement的特性。

2.3 何时更新?

InheritedWidget自身只负责数据的向下传递,子Widget可以从InheritedWidget中读出数据,但是,诸如我们的子Widget中的onPressed的回调函数中,对InheritedWidget中的数据进行修改,通常情况下是无法实现UI的更新的,因为InheritedWidget调用notifyClients()是有时机限制的。

仅当是ProxyElement#update()被调用时,才会调用updateShouldNotify()去评估是否要调用notifyClients去更新布局。而一般都数据修改,例如int++String赋值等等并不能触发notifyClients调用。

所以,只有Element#update()方法调用时,才能驱动子Widget发生视图更新,而Element#update()方法仅在:Element不变,Widget发生改变的时候才会触发,常见于Widget作为一个配置,发生了改变,而Element发生了复用的情况。比如State调用build方法构建了一个新的Widget子树,这个子树中的Widget都是全新的Widget,并且如果只是修改Text对应的String中的内容,Text对应的Element此时就会发生复用,这个过程就是Element的update(),即 用新的newWidget替换掉旧的oldWidget的过程,可以理解为Element的配置的改变。

所以,InheritedWidget的更新就必须依赖于InheritedWidget的上层更新,比如去调用setState等等,这个触发条件似乎有一点苛刻了,我们肯定是希望在子Widget中修改了InheritedWidget中的数据之后,就直接就能反应到视图。

我们可以在onPressed等回调方法中,调用完修改方法之后,手动调用一下setState来手动重建Widget,也可以在InheritedWidget中自己定义一个相关的方法,传入Context,统一处理。

3. ParentDataWidget

之前介绍InheritedWidget主要是讲了它作为ProxyWidget,它的notifyClients是如何实现的,作为ProxyWidget的另一个分支,ParentDataWidget也是一个非常常用的Widget,它的常见实现类包括:Flexible(常用Expanded)、Positioned等等。它们都有一个非常明显的特点:具有一个其父组件(Flext、Stack)需要的一个额外信息,父组件会使用这个额外的信息对当前组件进行布局、定位。

相比较于InheritedWidget,ParentDataWidget的使用场景更多的是偏向于视图本身的数据,比如尺寸、偏移量等等。

3.1 Positioned

首先我们来看看Positioned,Stack嵌套Positioned,在Positioned可以设置height/width和left/top/right/bottom等一系列的尺寸、位置属性,我们需要关注的,是ParentDataElement对应的的notifyClients究竟干了些什么。

我们先来看看Positioned的功能。Positioned先将传递进来的renderObject对象中的parentData结构取出,然后再向其中塞数据,之后的布局过程中,Stack就可以根据StackParentData中的数据进行布局了。

ParentDataElement的notifyClients方法,只调用了一个方法,我们可以快速地定位到_applyParentData方法:

@override
void applyParentData(RenderObject renderObject) {
 assert(renderObject.parentData is StackParentData);
 final StackParentData parentData = renderObject.parentData! as StackParentData;
 ……
}

这里传进来的正是Positioned的child属性对应的RenderObject,Positioned将设置的尺寸、偏移量作为一个StackParentData传递进去,然后再Render阶段对其进行位置的确定和布局。

接下来的场景如下:Stack下面套了三个Positioned,对应三个具有颜色的Container。

Positioned本身是不参与Render的,我们可以很清楚地看到,RenderStack的child直接就是RenderColoredBox,即一个具有颜色的Box,是由Container创建的,而不是一个Positioned(Container本身是一个复合型的StatelessWidget)。我们可以模糊地理解成,RenderTree下,Stack下直接就是Container。

ProxyWidget还是会存在于Element、Widget树当中的,只是在渲染的时候,它并不是一个RenderObject节点,所以,自然而然不参与渲染,但是它的数据还存在它的孩子对应的ParentData当中。重新构建时,也是调用renderObject.parent(在RenderTree上的parent,即Stack)进行重建

所以,ProxyWidget本身是不参与渲染的,他只作为一个中间Widget,为下层的Child对应的RenderObject,提供上层(Stack)所需要的数据(尺寸、偏移量等等)。

同为ParentDataWidget的Flexible同理,只不过把适用于Stack的StackParentData,换成了适用于Flex的FlexParentData,以StackParentData为例,我们只需要知道它的数据是记录在Postioned的child对应的RenderObject下,交给的父布局Stack使用即可,ParentDataWidget的使命也仅限于此

4. 后记

既然Positioned对应的Element也是ProxyElement的子类,那么它的notifyClients的调用就和InheritedWidget相同,当Element#update调用时,才会调用notifyClients,去重新为子Widget设置StackParentData(尺寸、宽高数据),然后去重新布局子Widget。

这也是ProxyElement一贯的处理方式,当ProxyWidget对应的数据发生改变(InheritedWidget一般是业务数据,ParentDataWidget一般是一些视图数据),才会去重建视图,而Widget数据发生改变的唯一方法,就是重新创建一个Widget,而不是在原有的Widget上通过回调等手段来进行赋值、增减等等,这种情况并不视为Widget的改变。

从Element的角度来说,如果Widget想要改变就必然要通过Element#update方法,即使是StatefulWidget,它的改变也是从State调用setState开始,然后StatefulWidget去rebuild一个新的Child Widget子树,再调用Element的update方法,将新的子树挂载上来完成新旧数据的更迭。

简单来说,默认情况下,数据的变更必须精确到Widget层面,Element才有可能看得见。

一旦认为数据发生了改变,那么ProxyElement则会通过notifyClients方法,通知所有的监听者,监听者此时的行为:

  • 如果是InheritedWidget,那么就是调用监听者的didChangeDependencies,重建监听者对应的视图。
  • 如果是ParentDataWidget,那么就是调用ParentDataElement的applyParentData函数,去重新build它的子集。
作者:开中断

%s 个评论

要回复文章请先登录注册