避免 Widget build(BuildContext context)带来的副作用

Flutter 是一个响应式 UI 框架,当需要展示界面的时候,框架通过 build 方法生成一帧的画面,当画面频繁变化时,flutter 会重复调用 build 方法来生成每一帧。所以不合适的函数调用或者当 build 和一些 Widget(例如:FutureBuilder,StreamBuilder)合用时,可能会产生一些副作用。

以下是会产生副作用的示例:

import 'random_color.dart' as color;

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: FutureBuilder(
          future: color.randomColors(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              final colors = snapshot.data;
              return Column(
                children: <Widget>[
                  Expanded(flex: 4, child: Container(color: colors[0])),
                  Expanded(flex: 3, child: Container(color: colors[1])),
                  Expanded(flex: 2, child: Container(color: colors[2])),
                  Expanded(flex: 2, child: Container(color: colors[3])),
                ],
              );
            } else {
              return Container();
            }
          }),
    );
  }
}

其中randomColor实现如下:

Future<List<Color>> randomColors() async {
  await loadData();
  return randomColorsSync();
}

var functionCallCount = 0;

List<Color> randomColorsSync() {
  assert(data.isNotEmpty);
  final code = data[Random().nextInt(data.length)];
  functionCallCount++;
  print("Function call count: $functionCallCount");
  return [
    code.substring(0, 6),
    code.substring(6, 12),
    code.substring(12, 18),
    code.substring(18, 24),
  ].map((e) => hexColor("#$e")).toList();
}

为了便于分析,我在randomColorSync函数内部添加了统计函数调用计数的代码,用于将调用次数显示在控制台上。

除此之外,我们创建了一个辅助 Widget:HomeWrapWidget来手动触发 Widget 重建:

class HomeWrapWidget extends StatefulWidget {
  @override
  _HomeWrapWidgetState createState() => _HomeWrapWidgetState();
}

class _HomeWrapWidgetState extends State<HomeWrapWidget> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MyHomePage(),
      floatingActionButton: Builder(
        builder: (context) => FloatingActionButton(
          onPressed: () => _rebuild(context),
          child: Icon(Icons.tag_faces),
        ),
      ),
    );
  }

  void _rebuild(BuildContext context) {
    setState(() {});
  }
}

最终的界面如下:

这是启动后的控制台日志:

I/flutter ( 4259): Function call count: 1

当我们重复点击悬浮按钮时,这个时候的控制台日志为:

I/flutter ( 4259): Function call count: 2
I/flutter ( 4259): Function call count: 3
I/flutter ( 4259): Function call count: 4
I/flutter ( 4259): Function call count: 5
I/flutter ( 4259): Function call count: 6
···

每次按下按钮后,界面的配色都发生了变化,重新获取了新的配色方案。

但是这并不是我们想要的效果,使用 FutureBuilder 的初衷时为了方便将 Future 数据映射为 UI,但是当我们更新界面其他部分时,却导致 FutureBuilder 再次获取了一遍数据,这种不符合直觉的结果在某些时候可能会产生一些错误。在示例部分我们使用了随机配色配合 FutureBuilder 构建 UI,所以当错误发生的时候,我们很明显可以观察到,但是如果将randomColor函数替换为一个 http 请求函数,当你请求同一个网址,返回相同的资源的时候,错误发生后,仅从界面是观察不到相应的变化的。

当我们更新界面其他部分的时候,却导致了对返回 Future 数据的资源的反复请求,尤其在发生路由页面动画的时候,会触发整个页面的 Widget 树重建,按照流畅应用 60FPS 的要求计算,对于 http 请求每秒会发送 60 次,算上动画时长,多设备多用户,对用户流量,对服务器都是极大的负担,同时每次请求返回不同的 Future 对象,还会导致 FutureBuilder 重复调用 builder 方法,这就是我在标题里提到的副作用。

对于这些副作用,解决方案有以下 3 种:

  1. 在 initState 方法里获取 Future 对象并缓存

    由于initState方法在整个 StatefulWidget 的生命周期中只会调用一次,所以对于 Future、Stream,可以在该方法中将需要用到的返回结果缓存下来,供后续使用。如果调用的方法需要传递BuildContext对象,也可以在didChangeDependencies方法中缓存调用结果。didChangeDependencies方法会在 Element 依赖发生改变的时候被调用。

    code:

    class _MyHomePage2State extends State<MyHomePage2> {
      Future<List<Color>> colorsFuture;
    
      @override
      void initState() {
        colorsFuture = color.randomColors();
        super.initState();
      }
    
      @override
      Widget build(BuildContext context) {
        return Container(
          color: Colors.white,
          child: FutureBuilder(
              future: colorsFuture,
              builder: (context, snapshot) {
                if (snapshot.hasData) {
                  final colors = snapshot.data;
                  return Column(
                    children: <Widget>[
                      Expanded(flex: 4, child: Container(color: colors[0])),
                      Expanded(flex: 3, child: Container(color: colors[1])),
                      Expanded(flex: 2, child: Container(color: colors[2])),
                      Expanded(flex: 2, child: Container(color: colors[3])),
                    ],
                  );
                } else {
                  return Container();
                }
              }),
        );
      }
    }
    
  2. 使用 AsyncMemoizer

    正如它的类名,AsyncMemoizer实质上就是一个内存缓存,runOnce方法保证只运行一次函数,并在之后使用缓存的异步结果。

    code:

    import 'package:async/async.dart' show AsyncMemoizer;
    
     class MyHomePage3 extends StatefulWidget {
       @override
       _MyHomePage3State createState() => _MyHomePage3State();
     }
    
     class _MyHomePage3State extends State<MyHomePage3> {
       final _memoizer = AsyncMemoizer<List<Color>>();
    
       @override
       Widget build(BuildContext context) {
         return Container(
           color: Colors.white,
           child: FutureBuilder(
               future: _memoizer.runOnce(color.randomColors),
               builder: (context, snapshot) {
                 if (snapshot.hasData) {
                   final colors = snapshot.data;
                   return Column(
                     children: <Widget>[
                       Expanded(flex: 4, child: Container(color: colors[0])),
                       Expanded(flex: 3, child: Container(color: colors[1])),
                       Expanded(flex: 2, child: Container(color: colors[2])),
                       Expanded(flex: 2, child: Container(color: colors[3])),
                     ],
                   );
                 } else {
                   return Container();
                 }
               }),
         );
       }
     }
    
  3. 由外部管理 Future

    这次不在 Widget 内部缓存 Future 结果,而是由外部管理,保证只获取一次,返回相同的 Future 对象

    code:

    var myColors = color.randomColors();
    
    class MyHomePage4 extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return FutureBuilder(
            future: myColors,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                return ColorListWidget(colors: snapshot.data);
              } else {
                return Container();
              }
            });
      }
    }
    
    class ColorListWidget extends StatelessWidget {
      final List<Color> colors;
    
      const ColorListWidget({Key key, @required this.colors}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Column(
          children: <Widget>[
            buildColorTile(flex: 4, color: colors[0]),
            buildColorTile(flex: 3, color: colors[1]),
            buildColorTile(flex: 2, color: colors[2]),
            buildColorTile(flex: 2, color: colors[3]),
          ],
        );
      }
    
      Widget buildColorTile({int flex = 1, Color color}) {
        return Expanded(
          flex: flex,
          child: Container(
            color: color,
            child: Stack(
              children: <Widget>[
                Positioned(
                  left: 0,
                  bottom: 0,
                  child: Text(
                    "#${color.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}",
                    style: TextStyle(
                      color: ThemeData.estimateBrightnessForColor(color) ==
                              Brightness.light
                          ? Colors.black.withOpacity(0.9)
                          : Colors.white.withOpacity(0.9),
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    

最终效果:

Github repository 地址