Flutter中如何避免多次build

3,182 阅读3分钟

Flutter中我们经常使用setState来进行页面的rebuild。但为了更新某一个widget而在父widget中直接setState,会导致很多不必要的多次build问题。这里我们就以bottomNavigationBar为例。 Flutter中要实现底部导航栏效果,系统已经提供了现成的方法供我们使用,比如使用Scaffold,他的bottomNavigationBar属性即是配置底部导航栏的地方,在切换时显示不同的body内容即可。所以它的一般使用方式如下:body采用PageView,当点击底部tab时切换PageView(这里假设已经会使用了)

class _BottomNavState extends State<BottomNavPage> {
 
  List<BottomNavigationBarItem> naviItems = [];
  PageController _pageController;
  int currentIndex=0;

  @override
  void initState() {
    super.initState();
    _pageController = PageController();
    buildItems();
  }



  void _pageChanged(int index) {

  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView.builder(
        itemBuilder: (context, index) {
          return PageTest(
            title: "Page $index",
            index: index,
          );
        },
        controller: _pageController,
        itemCount: 5,
        physics: NeverScrollableScrollPhysics(),
        onPageChanged: _pageChanged,
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        items: naviItems,
        showUnselectedLabels: true,
        type: BottomNavigationBarType.fixed,
        onTap: (int index){
          currentIndex = index;
          setState(() {

            _pageController.jumpToPage(index);
          });
        },
      ),
    );
  }

  void buildItems() {
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_dh.png", "Page1", "assets/xz_dh_icon_dh.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_dt.png", "Page2", "assets/xz_dh_icon_dt.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_xx.png", "Page3", "assets/xz_dh_icon_xx.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_xs.png", "Page4", "assets/xz_dh_icon_xs.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_wd.png", "Page5", "assets/xz_dh_icon_wd.png"));

  }

  BottomNavigationBarItem buildOriginalNavItem(String labelPath, String title,
      String activeLabelPath) {
    return BottomNavigationBarItem(
        icon: Image.asset(
          labelPath,
          width: 22,
          height: 22,
        ),
        label: title,
        activeIcon: Image.asset(
          activeLabelPath,
          width: 22,
          height: 22,
        ));
  }
}

PageView 的每一页我们都用了一个StatefullWidget,定义如下(实际中可以定义不同的页面,这里主要是为了方便)

class PageTest extends StatefulWidget {
  final String title;
  final int index;

  PageTest({this.title, this.index = 0,Key key}):super(key: key);

  @override
  State<StatefulWidget> createState() {
    print("${title} createState");

    return _PageTestState();
  }
}

class _PageTestState extends State<PageTest> with AutomaticKeepAliveClientMixin<PageTest>{
  List<Color> colors = [
    Colors.purple,
    Colors.deepOrange,
    Colors.orangeAccent,
    Colors.lightGreenAccent,
    Colors.pink[100]
  ];

  @override
  void initState() {
    super.initState();
    print("${widget.title} initState");
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print("${widget.title} didChangeDependencies");
  }

  @override
  void didUpdateWidget(covariant PageTest oldWidget) {
    super.didUpdateWidget(oldWidget);

    print("${widget.title} didUpdateWidget");
  }

  @override
  void dispose() {
    super.dispose();
    print("${widget.title} dispose");
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    print("${widget.title} build");
    return Scaffold(
      appBar: AppBar(
        leading: GestureDetector(
          child: Icon(Icons.arrow_back),
          onTap: () => onBackPressed(context),
        ),
        title: Text(widget.title),
      ),
      body:  Container(
        color: colors[widget.index],
      ),
    );
  }

  void onBackPressed(BuildContext context) {
    NavigatorState navigatorState = Navigator.of(context);
    if (navigatorState.canPop()) {
      navigatorState.pop();
    } else {
      SystemNavigator.pop();
    }
  }

  @override
  bool get wantKeepAlive => true;


}


可以看到在它的State类的定义中我们使用了AutomaticKeepAliveClientMixin,并且让wantKeepAlive方法返回了true。这么做的目的主要是想在tab切换时不重复创建。同时我们在他的createState,initState,didChangeDependencies,didUpdateWidget,build,dispose方法中都打印了log。那么这样到底能不能达到目的,我们可以看一下实现的效果以及从第1页切换到第5页的log

image.png 从第一页依次切换到第五页log

image.png

可以看到第一次加载时只是执行了第一页的createState,initState,didChangeDependencies,build这四个方法,切换到第二页时,除了执行第二页的同第一页的四个方法之外还执行了第一页的didUpdateWidget,build这两个方法,以后每切换一个新的页面除了自身执行上述四个方法,已经创建完的页面都会执行didUpdateWidget,build。都创建完之后,每次切换tab,每个页面都会执行这两个方法。比如从1依次切换到5,然后点击3

image.png

看来仅仅让PageView的页面保存状态,也不能解决多次build的问题。既然多调用了didUpdateWidgetbuild方法。那么我们就先看看上述这6个方法分别在什么时候会被调用。这其实就是通常所说的Statefullwidget的生命周期

image.png

可以看到生命周期方法如图所示,这也是为什么上面的log会添加在这几个方法里。其实这些方法可以分为三个阶段:创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树中移除)。

创建:

State 初始化时会依次执行 :构造方法 -> initState -> didChangeDependencies -> build,随后完成页面渲染。

  • 构造方法是 State 生命周期的起点,Flutter 会通过调用 StatefulWidget.createState() 来创建一个 State。我们可以通过构造方法,来接收父 Widget 传递的初始化 UI 配置数据。这些配置数据,决定了 Widget 最初的呈现效果。
  • initState,会在 State 对象被插入视图树的时候调用。这个函数在 State 的生命周期中只会被调用一次,所以我们可以在这里做一些初始化工作,比如为状态变量设定默认值。
  • didChangeDependencies 则用来专门处理 State 对象依赖关系变化,会在 initState() 调用结束后,被 Flutter 调用。
  • build,作用是构建视图。经过以上步骤,Framework 认为 State 已经准备好了,于是调用 build。我们需要在这个函数中,根据父 Widget 传递过来的初始化配置数据,以及 State 的当前状态,创建一个 Widget 然后返回。

更新:

Widget 的状态更新,主要由 3 个方法触发:setState、didchangeDependencies 与 didUpdateWidget。

  • setState:我们最熟悉的方法之一。当状态数据发生变化时,我们总是通过调用这个方法告诉 Flutter:“我这儿的数据变啦,请使用更新后的数据重建 UI!”
  • didChangeDependencies:State 对象的依赖关系发生变化后,Flutter 会回调这个方法,随后触发组件构建。哪些情况下 State 对象的依赖关系会发生变化呢?这个“依赖”指的就是子widget是否使用了父widget中InheritedWidget的数据!如果使用了,则代表子widget依赖有依赖InheritedWidget;如果没有使用则代表没有依赖。这种机制可以使子组件在所依赖的InheritedWidget变化时来更新自身!比如当主题、locale(语言)等发生变化时,依赖其的子widget的didChangeDependencies方法将会被调用。
  • didUpdateWidget:当 Widget 的配置发生变化时,比如,父 Widget 触发重建(即父 Widget 的状态发生变化时),热重载时,系统会调用这个函数。一旦这三个方法被调用,Flutter 随后就会销毁老 Widget,并调用 build 方法重建 Widget。

从生命周期的方法调用分析可以看出之所以每次切换tab会调用didUpdateWidget,build的原因是因为父widget触发了重建(因为我们在onTap方法中调用了setState)。如果我们不调用setState又无法更新tab的选中状态。所以我们的解决方法就是自定义一个bottomNavigationBar,首先我们看看Scaffold的这个属性(final Widget? bottomNavigationBar;)其实就是一个widget,而且是一个StatefullWidget,所以我们仿照这个写个简易版的bar,他的构造方法如下

BottomNavigationBar({
    Key? key,
    required this.items,
    this.onTap,
    this.currentIndex = 0,
    this.elevation,
    this.type,
    Color? fixedColor,
    this.backgroundColor,
    this.iconSize = 24.0,
    Color? selectedItemColor,
    this.unselectedItemColor,
    this.selectedIconTheme,
    this.unselectedIconTheme,
    this.selectedFontSize = 14.0,
    this.unselectedFontSize = 12.0,
    this.selectedLabelStyle,
    this.unselectedLabelStyle,
    this.showSelectedLabels,
    this.showUnselectedLabels,
    this.mouseCursor,
  }) 

具体的属性作用基本上可以猜到,我这里就不详述了。可以看到这里的items是必须的,它是一个list(final List<BottomNavigationBarItem> items),而这里具体的item为

class BottomNavigationBarItem {
 
  const BottomNavigationBarItem({
    required this.icon,
    @Deprecated(
      'Use "label" instead, as it allows for an improved text-scaling experience. '
      'This feature was deprecated after v1.19.0.'
    )
    this.title,
    this.label,
    Widget? activeIcon,
    this.backgroundColor,
    this.tooltip,
  }) : activeIcon = activeIcon ?? icon,
       assert(label == null || title == null),
       assert(icon != null);

  ...
}

其实就一个单纯的class,定义了每个tab的元素,比如选中的图标,未选中图光,背景,用于显示title的widget。至此系统封装的BottomNavigationBar元素分析已经结束了。我们也按照这种方式来封装自己的,比如这里的Item,我们定义如下

class CustomBottomNavItem  {
  ///图片下的标题
  final String title;

  ///显示的图片或者是自定义的widget(未选中)
  final Widget label;

  ///显示的图片或者是自定义的widget(选中)
  final Widget activeLabel;

  ///文本样式(选中)
  final TextStyle selectedStyle;

  ///文本样式(未选中)
  final TextStyle unselectedStyle;


  ///当前item在整个itemBar中的位置
  int index;



  CustomBottomNavItem(
      {Key key,
      this.title,
      this.label,
      this.selectedStyle,
      this.unselectedStyle,
      this.index,
      this.activeLabel});
}

这里主要声明了我们需要元素,那么真正的容器也按照系统封装的方式定义如下

class CustomNavBar extends StatefulWidget {
  ///所有的底部item
  final List<CustomBottomNavItem> items;

  ///选中时的文本样式
  final TextStyle selectedStyle;

  ///未选中时的文本样式
  final TextStyle unselectedStyle;

  ///tab的点击
  final ValueChanged<int> onTap;

  ///底部bar的整体高度
  final double height;

  ///底部bar的背景
  final Color bgColor;

  CustomNavBar(
      {Key key,
      this.items,
      this.selectedStyle,
      this.unselectedStyle,
      this.height,
      this.bgColor,
      this.onTap})
      : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return CustomNavBarStatus();
  }
}

接下来的重点是如何在它的父widget中不用调用setState来更新这个控件。如果不想采用setState更新,那么一般会有这两种方式,一种是通过可监听对象比如ValueNotifier,在对象变化时通过监听器来重建相应的view;另一种则是为这个Widget设置key,在父Widget中通过这个key拿到子控件,调用子控件的setState方法,从而只刷新子Widget。这里我们首先使用ValueNotifier来实现。其实之所以需要重建,是因为当前选中的tab也就是index在不断变化,所以我们的ValueNotifier修饰的应该是currentIndex,所以它的定义为

ValueNotifier<int> currentIndex = ValueNotifier<int>(0);

如果需要可配置当前选中的index,在CustomNavBar中定义一个属性传递进来即可,这里先写死。我们在看看开篇图实现的效果,每一个tab是图片和文字组成,每一个tab整体可以点击。所以我们先来定义这个子Widget,其实就是一个Column,为了点击我们需要用GestureDetector包裹一下。如果想要定义间距的话,直接在CustomNavBar中定义即可,这里就省略了。在点击的时候更改当前currentIndex的值,他就可以触发自动重建样式而不用外部setState

  Widget buildChild(CustomBottomNavItem item,int value) {
    return GestureDetector(
      onTap: () {
        if (widget.onTap != null) widget.onTap(item.index);
        currentIndex.value = item.index;
      },
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          item.index == value
              ? item.activeLabel == null
                  ? item.label
                  : item.activeLabel
              : item.label,
          Text(
            item.title,
            style: item.index == currentIndex.value
                ? item.selectedStyle
                : item.unselectedStyle,
          ),
        ],
      ),
    );
  }

上面提到,这里使用注册监听器的方式刷新,所以在点击的时候我们调用了currentIndex.value=item.index他实际上是调用了ValueNotifierset方法

  set value(T newValue) {
    if (_value == newValue)
      return;
    _value = newValue;
    notifyListeners();
  }

也就是发送了通知。所以我们要接收这个通知,并且创建这个widget。这里我们采用ValueListenableBuilder来监听这个通知。从而这个State中的build方法我们实现如下

 @override
  Widget build(BuildContext context) {
    return Container(
      key: barKey,
      height: widget.height,
      color: widget.bgColor,
      child: widget.items != null && widget.items.length > 0
          ? ValueListenableBuilder(valueListenable: currentIndex, builder: buildChildren)
          : Container(),
    );
  }

这里的buildChildren方法如下

Widget buildChildren(BuildContext context, int value, Widget child) {
   return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: widget.items.map((item) => buildChild(item,value)).toList(),
    );
  }

这里我们直接写死了。用row直接平分宽度,也就是原有控件的fixed模式,如果想滑动之类的buildChildren也可以采用ListView等。至此整个view就可以用了,我们试试看看是否达到了目的

class _BottomNavState extends State<BottomNavPage> {
  List<CustomBottomNavItem> items = [];
 
  PageController _pageController;
 

  @override
  void initState() {
    super.initState();
    _pageController = PageController();

     initItems();
  }

  CustomBottomNavItem buildNavItem(String labelPath, String title, String activeLabelPath,
     ) {
    return CustomBottomNavItem(
        label: Image.asset(
          labelPath,
          width: 22,
          height: 22,
        ),
        title: title,
        activeLabel: Image.asset(
          activeLabelPath,
          width: 22,
          height: 22,
        ));
  }

  void _pageChanged(int index) {

  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView.builder(
        itemBuilder: (context, index) {
          return PageTest(
            title: "Page $index",
            index: index,
          );
        },
        controller: _pageController,
        itemCount: 5,
        physics: NeverScrollableScrollPhysics(),
        onPageChanged: _pageChanged,
      ),
      bottomNavigationBar: CustomNavBar(
        items: items,
        height: 67,
        onTap: (index) {
          _pageController.jumpToPage(index);
        },
        selectedStyle: TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.bold,
            color: Color(0xff414344)),
        bgColor: Colors.greenAccent,
        unselectedStyle: TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.normal,
            color: Color(0xff414344)),
      ),

    );
  }

  void buildItems() {
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_dh.png", "Page1", "assets/xz_dh_icon_dh.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_dt.png", "Page2", "assets/xz_dh_icon_dt.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_xx.png", "Page3", "assets/xz_dh_icon_xx.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_xs.png", "Page4", "assets/xz_dh_icon_xs.png"));
    naviItems.add(buildOriginalNavItem(
        "assets/dh_icon_wd.png", "Page5", "assets/xz_dh_icon_wd.png"));

  }

  void initItem() {
    items.add(buildNavItem(
        "assets/dh_icon_dh.png", "Page1", "assets/xz_dh_icon_dh.png"));
    items.add(buildNavItem(
        "assets/dh_icon_dt.png", "Page2", "assets/xz_dh_icon_dt.png"));
    items.add(buildNavItem(
        "assets/dh_icon_xx.png", "Page3", "assets/xz_dh_icon_xx.png"));
    items.add(buildNavItem(
        "assets/dh_icon_xs.png", "Page4", "assets/xz_dh_icon_xs.png"));
    items.add(buildNavItem(
        "assets/dh_icon_wd.png", "Page5", "assets/xz_dh_icon_wd.png"));
  }
}

这会实现同样的效果,不过们可以看看log

image.png

首次加载页面时只会调用创建阶段的四个方法,以后无论怎们切换都不会调用生命周期方法。也就是说我们成功了。但是这里还有一点瑕疵,我们的PageViewphysics属性设置的是 NeverScrollableScrollPhysics()也就是只能点击tab切换吗,而不能滑动页面切换tab,如果不设置这个physics上面的代码在页面滑动时候下面的tab并没有相应的变化。如果想要有变化那么就得让他刷新。我们都知道在PageView滑动的时候会回调onPageChanged方法,所以在这里我们可以更新tab。这就用到了利用key来更新的方法。其实这个很简单,只需要在需要更新的State中定义一个key,并且把这个key用于build中,同时提供获取这个state的方法,比如这里的定义如下

  static GlobalKey barKey = GlobalKey();


  static currentInstance() {
    State<CustomNavBar> state =
        CustomNavBarStatus.barKey.currentContext.findAncestorStateOfType();
    return state;
  }

  setCurrentIndex(int index) {
    currentIndex.value = index;
  }

只需在onPageChanged方法中调用 CustomNavBarStatus.currentInstance().setCurrentIndex(index);即可更新这个tabbar。所以整个tabBar的完整代码为

class CustomNavBar extends StatefulWidget {
  ///所有的底部item
  final List<CustomBottomNavItem> items;

  ///选中时的文本样式
  final TextStyle selectedStyle;

  ///未选中时的文本样式
  final TextStyle unselectedStyle;

  ///tab的点击
  final ValueChanged<int> onTap;

  ///底部bar的整体高度
  final double height;

  ///底部bar的背景
  final Color bgColor;

  CustomNavBar(
      {Key key,
      this.items,
      this.selectedStyle,
      this.unselectedStyle,
      this.height,
      this.bgColor,
      this.onTap})
      : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return CustomNavBarStatus();
  }
}

class CustomNavBarStatus extends State<CustomNavBar> {
  static GlobalKey barKey = GlobalKey();


  static currentInstance() {
    State<CustomNavBar> state =
        CustomNavBarStatus.barKey.currentContext.findAncestorStateOfType();
    return state;
  }

  setCurrentIndex(int index) {
    currentIndex.value = index;
  }

  ValueNotifier<int> currentIndex = ValueNotifier<int>(0);

  @override
  void initState() {
    super.initState();
    refreshChild();
  }

  void refreshChild() {
    for (int i = 0; i < widget.items.length; i++) {
      widget.items[i].index = i;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      key: barKey,
      height: widget.height,
      color: widget.bgColor,
      child: widget.items != null && widget.items.length > 0
          ? ValueListenableBuilder(valueListenable: currentIndex, builder: buildChildren)
          : Container(),
    );
  }

  Widget buildChild(CustomBottomNavItem item,int value) {
    return GestureDetector(
      onTap: () {
        if (widget.onTap != null) widget.onTap(item.index);
        currentIndex.value = item.index;
      },
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          item.index == value
              ? item.activeLabel == null
                  ? item.label
                  : item.activeLabel
              : item.label,
          Text(
            item.title,
            style: item.index == currentIndex.value
                ? item.selectedStyle
                : item.unselectedStyle,
          ),
        ],
      ),
    );
  }

  Widget buildChildren(BuildContext context, int value, Widget child) {
   return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: widget.items.map((item) => buildChild(item,value)).toList(),
    );
  }
}

至此整个过程就完毕了,欢迎大家指正