停止 FutrueBuilder 的重复刷新和执行

5,628 阅读4分钟

一、问题概述

之前在使用 FutureBuilder 的过程中发现了一个问题,就是每次刷新界面的时候,FutrueBuilder 中的 future 函数都会执行,虽然不会造成什么严重问题(甚至有的时候需求就是这样的),但是在某些情况下,这个问题是一定要解决的(后面会说明)。

github 上有一个相关的 issue: FutureBuilder fire

我在 Medium 上看到了一篇相关的文章来解释这个问题,这里分享给大家。

二、问题复现

要复现这个问题很简单,只要页面中有 FutureBuilder 组件,那么每次调用 setState 都会导致 FutureBuilder 刷新一次,future 函数执行一次。

假设我们的代码如下:

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeScreen()
    );
  }
}

class HomeScreen extends StatefulWidget {
  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {

  bool _switchValue;

  @override
  void initState() {
    super.initState();
    this._switchValue = false;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Switch(
            value: this._switchValue,
            onChanged: (newValue) {
              setState(() {
                this._switchValue = newValue;
              });
            },
          ),
          FutureBuilder(
              future: this._fetchData(),
              builder: (context, snapshot) {
                switch (snapshot.connectionState) {
                  case ConnectionState.none:
                  case ConnectionState.waiting:
                    return Center(
                      child: CircularProgressIndicator()
                    );
                  default:
                    return Center(
                      child: Text(snapshot.data)
                    );
                }
              }
          ),
        ],
      ),
    );
  }

  _fetchData() async {
    await Future.delayed(Duration(seconds: 2));
    return 'REMOTE DATA';
  }
}
  • Switch 组件状态会触发 setState()
  • FutrueBuilder 通过 _fetchData() 来模拟从服务器获取数据

运行上面的代码,会出现下面的情况。

可以发现,每次 switch 状态的改变,都会导致 FutrueBuilder 刷新一次,但是在上面的代码里,switch 和 FutureBuilder 其实是没有任何联系的。由于任何 setState 操作都会导致 FutureBuilder 刷新一次,那么可能就会导致以下问题:

  • 当界面已经不可见时依然有代码在执行(消耗性能与流量等)
  • 热重载不正确
  • 在 Inherited 组件里面更新数值将导致 Navigator 状态丢失
  • etc...

三、原因分析

FutureBuilder 其实是一个 StatefulWidget 类型的组件。

class FutureBuilder<T> extends StatefulWidget {
  /// Creates a widget that builds itself based on the latest snapshot of
  /// interaction with a [Future].
  ///
  /// The [builder] must not be null.
  const FutureBuilder({
    Key key,
    this.future,
    this.initialData,
    @required this.builder,
  }) : assert(builder != null),
       super(key: key);
...
}

StatefulWidgets 组件将会持有一个生命周期很长的 State 对象,这个 State 对象有很多方法和生命周期有关。

  • initState : 在对象创建之后调用一次。
  • build 每次当我们刷新展示组件时,这个方法都会被调用。
  • didUpdateWidget 当一个老组件被回收一个新组件重建,并且这个新 Widget 和当前 State 对象绑定到一起的时候,didUpdateWidget 就会被回调。简单的说就是,当一个组件关联的 State 对象改变时,didUpdateWidget 就会执行,这里可以进行一些 rebuild 之前需要进行的操作。

在 FutureBuilder 里面,didUpdateWidget方法重写如下:

@override
void didUpdateWidget(FutureBuilder<T> oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.future != widget.future) {
    if (_activeCallbackIdentity != null) {
      _unsubscribe();
      _snapshot = _snapshot.inState(ConnectionState.none);
    }
    _subscribe();
  }
}

在这里会进行判断,当新的 widget 和 老的 widget 的 future 不是同一个对象实例的时候,就会重复执行一遍: _unsubscribe() 和 _subscribe() ,也就是 FutureBuilder 的生命周期重新走了一遍。

因此现在的问题就是,每次界面刷新的时候,FutureBuilder 老状态组件的 future 实例都和新状态组件的 futrue 实例不是同一个,那么我们要做的就是,让 FutureBuilder 新老组件获取相同的 future 实例即可。

四、解决方案

在一些函数式语言里面,当一个函数确定之后,只要输入相同,那么这个函数一定会输出相同的结果,因此我们可以把这个结果存储起来,当这个函数再次被调用的时候,我们直接返回上一次运行的结果即可。

放到我们这里就是,我们需要把 futrue 对象实例存储起来,每次都获取这个相同的实例,就可以解决上面的问题。

dart 里面正好提供了一个类 AsyncMemoizer 可以满足我们的需求。

总结一下就是,这个类可以保证函数只执行一次,并且把结果存储起来,当这个函数多次被调用时,就把这个结果返回。

要使用这个类,先他添加 async 库依赖(注意依赖版本不要冲突):

  async: ^2.3.0

我们在代码里面初始化 AsnycMemorizer 对象实例:

final AsyncMemoizer _memoizer = AsyncMemoizer();

最后把之前定义的 future 函数结合 memorizer 使用:

_fetchData() {
  return this._memoizer.runOnce(() async {
    await Future.delayed(Duration(seconds: 2));
    return 'REMOTE DATA';
  });

最终效果如下:

这里可能有人有疑问,就是这样实现之后,每次刷新都不会再刷新 FutureBuilder 了,那么如果我想控制到底要不要刷新,我该怎么做?我觉得可以这样,加入一个变量,根据这个变量来判断是否返回相同实例,从而达到是否刷新的效果。

  _fetchData(bool flag) async{
    if(flag){
      return this._memoizer.runOnce(() async {
        await Future.delayed(Duration(seconds: 2));
        return 'REMOTE DATA';
      });
    }else{
      await Future.delayed(Duration(seconds: 5));
      return 'REMOTE DATA';
    }
  }

github

最后

欢迎关注「Flutter 编程开发」微信公众号 。