Flutter底层学习:页面机制

jkouu 262 0

Flutter的页面机制是一项非常重要的内容,我们能否写出好的工程代码,甚至说能否写出一个好的框架,都取决于我们对页面机制的理解。这篇文章包含了我个人在学习相关内容时的一些总结,算是抛砖引玉,希望能让大家对Flutter的页面机制有更深的思考。

1.Widget、Element和Object

1.1.Widget

StatelessWidget是Widget子类中最简单的一个,我们就以Stateless为例来看一下Widget到底是怎么作用到页面上的。

abstract class StatelessWidget extends Widget {
  const StatelessWidget({ Key key }) : super(key: key);
  
  @override
  StatelessElement createElement() => StatelessElement(this);
  
  @protected
  Widget build(BuildContext context);
}

上面是StatelessWidget的源码。我们看到,它其实非常的简单,只有三个函数。构造函数没有什么实际的意义,而需要我们重写的build函数是Flutter框架对外暴露的接口,也不需要过多关注,那么实际有意义的就只有它实现的createElement函数了。

class StatelessElement extends ComponentElement {
  StatelessElement(StatelessWidget widget) : super(widget);

  @override
  StatelessWidget get widget => super.widget as StatelessWidget;

  @override
  Widget build() => widget.build(this);

  @override
  void update(StatelessWidget newWidget) {
    super.update(newWidget);
    assert(widget == newWidget);
    _dirty = true;
    rebuild();
  }
}

上面是StatelessElement的部分源码。我们看到,调用StatelessWidget的build函数的,恰恰是StatelessWidget创造的StatelessElement。那么这个StatelessElement到底是什么东西呢?谷歌在注释中用了短短一句话来解释它:

An Element that uses a StatelessWidget as its configuration.

这句话翻译过来就是:StatelessElement是把StatelessWidget作为自己的配置的Element

从这句话就可以看出来,StatelessElement是Element的子类。所以我们从这句话能推理出来的信息就是:Widget就是Element的配置

1.2.Element

既然Widget是Element的配置,那么说明其实Element才是页面搭建任务的承担者。我们已经知道了,Element是通过Widget的createElement函数创建的,那么这个函数会在什么时候被调用呢?通过Android Studio的查找,我们发现只有两个函数调用了createElement:Element类的inflateElement和RenderObjectToWidgetAdapter类的attachToRenderTree。

显然,Element不可能调用自己的函数来创建自己,所以inflateElement是用来创建一个Element的子Element的。那么理所当然,第一个Element就是由attachToRenderTree这个函数来创建的。

当然,我们可以快速验证这个结论:你只需要查看Flutter程序的入口函数——runApp,就会发现这个函数会创建一个单例的WidgetsBinding实例,这个实例有一个scheduleAttachRootWidget函数,参数就是runApp的参数,也就是我们传入的App实例(一般都是MaterialApp,显然这个类也是Widget的子类),这个函数就会调用attachToRenderTree函数,创建一个以App为配置的Element。为了帮大家省事,我就把相关的函数放在下面了:

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

static WidgetsBinding ensureInitialized() {
  if (WidgetsBinding.instance == null)
    WidgetsFlutterBinding();
  return WidgetsBinding.instance;
}

@protected
void scheduleAttachRootWidget(Widget rootWidget) {
  Timer.run(() {
    attachRootWidget(rootWidget);
  });
}

在编码过程中,我们常常说Flutter的页面其实是一个Widget树,树的顶层是App(一般是MaterialApp)。现在我们知道,这么说是不对的,Widget实际上是Element的配置,所以其实在程序运行的第一步,Widget树就已经被转化成Element树了。

1.3.RenderObject

如果我们仔细看Element的源码,我们会发现,Element除了持有一个Widget,还会持有一个RenderObject。那么什么是RenderObject呢?谷歌在源码的注释给了答案:

 An object in the render tree.

注释简洁明了:RenderObject就是render树上的一个object。render的意思是渲染,那么RenderObject顾名思义就是用来对页面进行绘制的类。由于RenderObject绘制页面的过程涉及到Flutter框架更加底层的部分,这部分内容与我们今天要学习的重点相关性不大,这里我们就不再展开了。但是,既然Element也持有了RenderObject,那么我们也可以推理出来,一个RenderObject是对应一个Element的。也就是说,在绘制过程中,Element树还会进一步被转换成Render树来提交给Flutter的Framework部分的下一层。鉴于目前我们只关注Flutter的Framework部分中的Widget层,所以对于RenderObject了解到这个程度也就足够了。

Flutter底层学习:页面机制

本篇文章的着眼点是在Framework部分的Widgets层,RenderObject可以看作是Widgets层和Rendering层的媒介

2.BuildContext

讲完了三棵树,我们再来看一看页面机制的另外一个重要内容:BuildContext。

2.1.什么是BuildContext

BuildContext一般出现在两个地方:Widget的build函数的参数以及以Navigator为代表的.of函数的参数。为了了解BuildContext这个类在这两个地方的作用,我们首先就要搞明白到底什么是BuildContext。

[BuildContext] objects are actually [Element] objects. The [BuildContext]

interface is used to discourage direct manipulation of [Element] objects.

上面是谷歌写在Context类前的注释。其实这两句话就已经足够说明了BuildContext到底是什么了——它其实就是Element。如果我们翻源码,我们也会发现,Element其实就是实现了BuildContext(侧面说明了BuildContext不是一个java意义上的抽象类,而是一个接口)。之所以设计了BuildContext这个接口,是因为谷歌不希望直接把Element这个框架内部的实现类暴露在外面。

在了解了这一点后,我们再反过来看build这个函数,会觉得Widget和Element的关系成立地是那么的自然——函数返回的Widget,其实就是对应着参数的这个Element。

2.2.of(context)方法

然后我们再说一下of(context)函数。这样的函数在Flutter中有很多,比如Navigator.of(context),Theme.of(context),Scaffold.of(context),Provider.of(context)等等。这些函数的参数context显然是我们当前正在编码的Widget所对应的Element,而这些函数的作用往往是获得一个数据对象。

通常来说,获得的数据对象是一个State(比如说Navigator.of(context)获得的是NavigatorState,Provider.of(context)获得的是我们自定义的一个Model,这某种意义上也算是一个State)。但是,无论是NavigatorState也好,Model也好,它们显然是不属于我们当前所编码的Widget对应的Element的。那么我们就可以推测,所有的这些of(context)函数,作用实际是就是从参数给出的element出发,向上查找到某一个特定的element,获取对应的数据。

为了验证我们的猜想,我们来看一下Navigator.of(context)的实现。

static NavigatorState of(
    BuildContext context, {
      bool rootNavigator = false,
      bool nullOk = false,
    }) {
    
    final NavigatorState navigator = rootNavigator
        ? context.rootAncestorStateOfType(const TypeMatcher<NavigatorState>())
        : context.ancestorStateOfType(const TypeMatcher<NavigatorState>());
        
    assert(() {
      if (navigator == null && !nullOk) {
        throw FlutterError(
          'Navigator operation requested with a context that does not include a Navigator.\n'
          'The context used to push or pop routes from the Navigator must be that of a '
          'widget that is a descendant of a Navigator widget.'
        );
      }
      return true;
    }());
    return navigator;
  }

从源码中可以看到,Navigator.of(context)函数,实际上是通过context的rootAncestorStateOfType和ancestorStateOfType两个方法来获取NavigatorState的。那么这两个方法是用来干什么的呢?

/// Returns the [State] object of the furthest ancestor [StatefulWidget] widget
/// that matches the given [TypeMatcher].
///
/// This method is deprecated. Please use [findRootAncestorStateOfType] instead.
// TODO(a14n): Remove this when it goes to stable, https://github.com/flutter/flutter/pull/44189
@Deprecated(
  'Use findRootAncestorStateOfType instead. '
  'This feature was deprecated after v1.12.1.'
)
State rootAncestorStateOfType(TypeMatcher matcher);

/// Returns the [RenderObject] object of the nearest ancestor [RenderObjectWidget] widget
/// that matches the given [TypeMatcher].
///
/// This method is deprecated. Please use [findAncestorRenderObjectOfType] instead.
// TODO(a14n): Remove this when it goes to stable, https://github.com/flutter/flutter/pull/44189
@Deprecated(
  'Use findAncestorRenderObjectOfType instead. '
  'This feature was deprecated after v1.12.1.'
)
RenderObject ancestorRenderObjectOfType(TypeMatcher matcher);

上面是谷歌在源码中写的注释。可以看到,这两个函数的作用其实就是从Element树上找到离参数的context最远/近的StatefulElement,这个Element所持有的State与给定的State类型是相符的。这样一来,我们的猜想就得到了证实。

实际上,如果我们看了BuildContext的源码,我们会发现,BuildContext还有更多关于从Element树上获得数据的函数。下面是其中的一些。

ancestorInheritedElementForWidgetOfExactType(Type targetType) → InheritedElement

ancestorRenderObjectOfType(TypeMatcher matcher) → RenderObject

ancestorStateOfType(TypeMatcher matcher) → State

ancestorWidgetOfExactType(Type targetType) → Widget

findRenderObject() → RenderObject

inheritFromElement(InheritedElement ancestor, { Object aspect }) → InheritedWidget

inheritFromWidgetOfExactType(Type targetType, { Object aspect }) → InheritedWidget

rootAncestorStateOfType(TypeMatcher matcher) → State

visitAncestorElements(bool visitor(Element element)) → void

visitChildElements(ElementVisitor visitor) → void

3.key

在正式介绍key之前,我们需要再回头看一下前面学到的三棵树。

3.1.三棵树之间的关系

要明确三棵树之间的关系,其实只需要明确Widget、Element和RenderObject三者之间的关系就好了。这里我用MVC模式来对它们的关系做一个类比。

MVC模式大家都很熟悉,Model发生变化时通知Controller,Controller再根据变化后的Model对View进行更新。对于Widget、Element和RenderObject这三者来说,Widget就好比是Model,它存放着绘制一个控件所需要的全部数据;RenderObject就好比是View,它用来把Model中的数据转化为屏幕上表现的图像;那么同时持有Widget和RenderObject的Element当然就是Controller了。

与一般的MVC不同的是,作为Model的Widget是不可变的。既然不可变,那么自然而然地,Model的更新就是一个问题。Flutter的解决方案也很简单:废除旧的Widget,创建一个新的Widget给Element,这样就相当于Widget进行了一次数据更新。这样的处理方式就使得Widget树非常的不稳定——因为它或者它的子树需要不断地销毁重建。

然后是通知Controller发生了变化。Element有一个变量叫做dirty。谷歌对它的解释是这样的:

Returns true if the element has been marked as needing rebuilding.

也就是说,当Widget变化后,只需要将对应的Element的dirty标记为true就可以了。Flutter会在下一帧令所有被标记为dirty的Element进行视图的更新。

当然,如果每一次Widget树的重构都要触发Element树的重构的话,那Flutter的效率就太慢了——因为Element的创建还涉及到和渲染层的同步。考虑到Widget树会不断地销毁重建,如果Element树要随着Widget树的重建而重建,那么系统的开销就会非常大。于是谷歌在Element树的更新这里做了一些优化。

Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
  if (newWidget == null) {
    if (child != null)
      deactivateChild(child);
    return null;
  }
  Element newChild;
  if (child != null) {
    bool hasSameSuperclass = true;
    if (hasSameSuperclass && child.widget == newWidget) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      newChild = child;
    } else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
      if (child.slot != newSlot)
        updateSlotForChild(child, newSlot);
      child.update(newWidget);
      newChild = child;
    } else {
      deactivateChild(child);
      newChild = inflateWidget(newWidget, newSlot);
    }
  } else {
    newChild = inflateWidget(newWidget, newSlot);
  }
  return newChild;
}

上面是Element的updateChild函数。我们看到,Element树上每一个位置的更新都是在其父节点上进行的(因为顶层的Element,即App对应的Element是不会更新的,这样就可以从根节点开始,以dfs策略来遍历Element树进行更新)。谷歌将Element节点的更新分成了下面几种情况:

  1. 新的Widget是空的。这表明Element树的这个位置在下一帧不该有Element。如果此时这个位置有Element,那么就要把它移除(deactivateChild)。这种情况对应的是控件被移除的情况。
  2. 子Element不为空,且新Widget和老Widget相比属性没有发生变化,但是位置发生了变化。这种情况下不需要重新生成Element,只需要把Element在Element树上的位置更新一下(updateSlotForChild)就可以了。
  3. 子Element不为空,新Widget虽然和旧Widget不同,但是可以直接替换旧的Widget。显然这种情况也不需要销毁和创建Element,只需要复用原来的Element就好了。
  4. 子Element不为空,新Widget虽然和旧Widget不同,而且新的也没有办法直接替换旧的。这种情况下只能移除原来的Element,然后再根据新的Widget来创建一个新的Element了。
  5. 子Element为空。这意味着原来这个位置是没有Element的,这时直接创建一个新的就好。

从上面的可以看到,逻辑分支的判断的条件有四个:Widget是否为空、子Element是否为空、旧Widget和新Widget是否相等、新Widget能否直接替换旧Widget。前面三个都很好解决,重点在于如何判断新Widget能否直接替换旧Widget——这就是key的作用了。

3.2.key

从updataChild函数中我们看到,判断新Widget能否直接替换旧Widget的函数是Widget类的静态方法canUpdate函数。

static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      && oldWidget.key == newWidget.key;
}

上面是canUpdate函数的源码。函数非常简单,就是比较两个Widget的类型和key。在不指定key时,只要两个Widget类型相同,Flutter就认为它们是可替换的。只有在指定了key时,Flutter才会判断两个同类型的Widget能否替换。可以说正是因为有了key,Element的更新机制才能适应更多的业务场景。

这里我们举一个例子来进一步说明。这个例子是大家在网上很常见的两个不同颜色的正方形互换的代码,它出自于Flutter官方的关于key的一个讲解视频。

class StatelessContainer extends StatelessWidget {
  final Color color = RandomColor().randomColor();
  
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: color,
    );
  }
}

class Screen extends StatefulWidget {
  @override
  _ScreenState createState() => _ScreenState();
}

class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    StatelessContainer(),
    StatelessContainer(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: widgets,
        ),
      ),
      floatingActionButton: FloatingActionButton(
          onPressed: switchWidget,
        child: Icon(Icons.undo),
      ),
    );
  }

  switchWidget(){
    widgets.insert(0, widgets.removeAt(1));
    setState(() {});
  }
}

首先是两个StatelessWidget,它们没有被指定key值。当Widget互换的时候,显然canUpdate返回的是true。它们两个对应的StatelessElement交换了彼此的Widget,然后调用新Widget的build方法绘制页面。

class StatefulContainer extends StatefulWidget {
  StatefulContainer({Key key}) : super(key: key);
  @override
  _StatefulContainerState createState() => _StatefulContainerState();
}

class _StatefulContainerState extends State<StatefulContainer> {
  final Color color = RandomColor().randomColor();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: color,
    );
  }
}

现在我们把StatelessWidget改成StatefulWidget,仍然不传入key值。这时,canUpdate返回的仍然是true,两个Element仍然交换了彼此的Widget。但是,因为build函数是在State中,而State是被Element持有的。因为Element没有变,所以即使Widget变量,State也还是没有变,build方法自然也还是最开始的build方法。所以这时我们就会发现两个方块无法交换。

为了解决这个问题,我们给这两个StatefulWidget各自指定一个key值。这时,canUpdate函数返回值就变成了false。根据updateChild的逻辑,这两个StatefulElement会被销毁,然后根据新的Widget重建。这样,Element对应的State就被更新了,方块就又可以交换了。

class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(key: UniqueKey(),),
    ),
    Padding(
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(key: UniqueKey(),),
    ),
  ];
}

接着,视频又对代码做了一次修改,这次在两个StatefulWidget外各自套了一个Padding。这时我们发现,程序不再是交换两个方块,而是不断创建两个新的方块。还记得前面我们说的“Element树上每一个位置的更新都是在其父节点上进行的”这句话吗?因为两个StatefulWidget外各自套了一个Padding,所以在更新它们的过程是在它们各自的父节点,也就是两个Padding上进行的。这时canUpdate返回的是false,所以Flutter销毁了旧的Element并创建了新的Element,最后的效果和我们想要的交换两个Element的效果当然就不一样了。

那么这种情况下怎么 实现交换两个方块的效果呢?简单,把两个Padding交换就好了,这样作为Padding子树成员的两个方块就可以 跟着一块被交换过去了。

class _ScreenState extends State<Screen> {
  List<Widget> widgets = [
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(),
    ),
    Padding(
      key: UniqueKey(),
      padding: const EdgeInsets.all(8.0),
      child: StatefulContainer(),
    ),
  ];
}

3.3.key的种类

key的种类有很多,比如ValueKey、ObjectKey、UniqueKey、PageStorageKey、GlobalKey等。但是它们的原理都相同——通过比较自己存储的一个值来判断是否相等。对于UniqueKey来说,这个值是一个由Widget生成的哈希码;对于ObjectKey来说,这个值是创建时给定的一个对象;对于ValueKey来说,这个值是创建时给定的一个特殊值;对于GlobalKey来说,这个值是BuildContext、Element或State。(PageStorageKey本身是ValueKey的一个实现)

那么,如何正确选择使用哪种Key呢?这当然取决于具体的业务场景。简单来说,Key就好比数据库的主键。如果一个属性就能成为主键,那么就用ValueKey;如果多个属性才能成为主键,那么就用ObjectKey;如果两个都不能解决问题,就用UniqueKey(当然最好避免用它,因为Widget每一次build,UniqueKey保存的哈希值都会变,这实际上很不稳定)。关于GlobalKey,从它存储的数据种类我们就可以知道,这种Key是用来跨组件取值的,所以一般情况下不要用它(如果看源码就会发现,GlobalKey是用一个Map来维护的,开销非常大)。

 

发表评论 取消回复
您必须 [登录] 才能发表评论!
分享