重磅-flutter视图局部更新

31次阅读

共计 4441 个字符,预计需要花费 12 分钟才能阅读完成。

新建一个 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 节点结构像挂钩, 只有 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, 还是有多个查询操作, 这么麻烦还不如全部新建呢.

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

正文完
 0