重磅! flutter视图局部更新

8,574 阅读6分钟

新建一个flutter工程, 以flutter框架给我们自动生成的代码为例, 当我们点击按钮更新记数_counter时,最终是通过调用State<T>.setState来更新视图的:

setState(() {
  _counter++;
})

首先需要理解为什么要setState, 它表示当前节点的数据变更,通知视图需要更新.更新哪个视图? 持有当前这个State实例的节点对应的视图. 注意这个节点具体指的是Element对象, Widget只是创建State实例(_MyHomePageState createState()),并没有持有, 同样State又继续创建了子视图,也没有持有子视图(Widget build(BuildContext context)), 持有State的只有Element. setState的参数是一个方法执行体, 实现哪些数据的具体变更, 所以其实没有设置所谓的状态, 还不如叫notifyChanges来的明晰.

其次需要理解视图如何更新. 像Text那个控件, 文本是作为构造函数的参数直接传给控件的, 根本连类似setText的方法也没有! 所以显示出来的数据要更新除了新建视图对象外没有别的办法!

这里就体现了flutter与传统移动端界面开发的巨大不同: 视图是通过新建视图对象来完成更新的. 以往的界面开发中视图对象都是一个比较重比较大的对象, 视图要避免冗余, 要尽量复用, 不要频繁创建. 但在flutter中就不是这样了, 代表视图对象的Widget是轻量对象, 它不持有State, 也不持有Widget, 所有视图对象都是通过build这种创建型关系建立. 所以开发过程中也要坚决避免自定义的Widget持有数据, 因为Widget对象会被很快替换掉.

有了上述两点就能明白setState之后发生了什么: 当前_MyHomePageStateWidget build(BuildContext context)方法会被调用, 于是生成了新的Scaffold对象,连带着AppBar,FloatingActionButton,Column一干控件其中自然包括我们需要展示的Text对象, 这时传入的文本是更新过后的_counter,于是视图得以更新.

只是想更新一个个小小的文本框就不得不重新创建整个视图?!

对, 目前的机制就是这样. 那随着视图层次加深, 界面交互复杂,这种重新创建型操作就没有一点问题? 毕竟对象再小也有开销, 那么多对象累积起来,也可能造成创建过程的消耗.于是我们的问题终于来了: 有没有方法可以只更新部分视图?

缩小一下更新范围不就得了? 现在的更新范围大是因为_MyHomePageState.build被调用返回了整个视图, 而_MyHomePageState对应的视图是MyHomePage. 所以创建一个State<Text>, build返回Text控件实例, 再将这个State<Text>持有, 数据变更时调用State<Text>.setState()`不就可以达到目的?

这个想法符合flutter本身的机制, 但问题就是谁来创建这个State<Text>? 如前文所述, 首先只有StatefulWidget才能创建State实例, 其次必须是父节点创建这个State<Text>. 但示例中Text的父节点Column首先就不是StatefulWidget; 就算是了, 我们还要声明Widget类继承Column覆盖build方法, 再声明State类继承State<Text>, 烦都烦死了. 那如果从Text向上找一个StatefulWidget, 创建的时候是Text的一个祖先节点, 存在一点冗余可以接受呢? 这个想法实践上一点也不可行, 且不说有个特定视图对象的查找过程, 上面所说的各种类声明一点也没有减少, 所以这个路子是没法搞的.

所以还是从setState源码入手, 看一个节点到底是如何更新视图的.

State.setState
  Element.markNeedsBuild
    Element._dirty = true;
    BuildOwner.scheduleBuildFor
      BuildOwner._dirtyElements.add
      Element._inDirtyList = true;

过程比想象的简单, 最后仅仅是将Element节点标识成dirty并加入到了BuildOwner的_dirtyElements列表里. 从Element角度看setState这个名称似乎也没有错, 不过它是相对Element说的, 具体设置的是Elementdirty状态. 那我们只需找到Text对应的Element节点并调用一下它的markNeedsBuild不就ok了? 所以先要找到Text这个Widget节点对应的Element节点.

在以前的建树流程中说过Element节点结构像挂钩, Element只有parent没有children, 要找子节点需要像Element.visitChildren那样传递一个访问者来进行遍历, 而判断条件自然就是Element持有的Widget是否是我们需要更新的Widget, 于是有:

  static Element findChild(Element e, Widget w) {
    Element child;
    void visit(Element element) {
      if (w == element.widget)
        child = element;
      else
        element.visitChildren(visit);
    }
    visit(e);
    return child;
  }

但是对找到的element设置markNeedsBuild竟然不起作用! 查了半天原因, 才明白还是把建树流程搞混了, markNeedsBuild仅让当前Element节点的build被调用, 创建的是当前节点的子节点视图对象, 而我们现在需要的是把当前子节点持有的视图对象替换掉('视图更新是通过创建新的Widget对象'), 同时不能重新创建当前Element节点及其子节点. 而Element.update(Widget)正是这个作用!! 如果说inflateWidget是初始化Element节点树, 那update正是在树建立成功后进行更新操作. 于是有

onPressed: () {
  _counter++;
  Element e = findChild(context as Element, title);
  if (e != null) {
    e.update(title);
  }
},

因为要找节点, 所以用了一个title持有了Text, 以方便在onTap()的上下文中作查找参数. 但这样也是不对的! 这里存在2个问题:

  1. 视图对象没有更新. 我们需要展示的是一个新的_counter相关的文本, 因此需要的是一个新的视图对象, 现在传入的还是老的视图对象,等于什么也没更新...
  2. 直接调用Element.update是有异常的, 跟踪了一下发现一个标识状态的数据_debugStateLockLevel不对, 原来要在BuildOwner.lockState中执行才可以.

这里啰里八嗦的写这一坨是想表明一个的新想法的实现是环环相扣关联细节的, 很多时候思路是对的, 但细节实现错误导致半途而废, 行百里者半九十!

还是上完整代码, findChild前面已定义就不再贴了:

import 'package:flutter/foundation.dart'
import 'package:flutter/material.dart';
import 'utils/ElementUtils.dart';

void main() {
  runApp(new MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Pages'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    Widget title = new Text(
      'another times: $_counter',
    );
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
            title,
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _counter++;
          Element e = findChild(context as Element, title);
          if (e != null) {
            title = new Text(
              'another times: $_counter',
            );
            e.owner.lockState(() {
              e.update(title);
            });
          }
        },
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

现在只是重新创建了仅仅一个视图哦, 它不快都不行~!

然而还是需要考虑一下这么做的缺点或者劣势是什么

首先, 明显的存在一个查询操作, 这是由Element机制决定的, 遍历只能通过访问者模式, 时间复杂度O(n), 能不能避免这个查询或者建立Widget到Element的映射? 也可以, 但是至少要查询一次,因为创建widget的时候Element可能还没创建或者还没有关联, 只有Element树建立完成之后才能查的到.

其次, 如果一个操作涉及多个视图的更新, 我们不得不持有多个widget, 并查找多个widget对应的element, 还是有多个查询操作, 这么麻烦还不如全部新建呢.

所以只能视情况而定, 没有包打天下一劳永逸的方案, 合适的才是最好的!