Flutter中如何避免多次build

縱馬天下發表於2021-06-02

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(),
    );
  }
}

複製程式碼

至此整個過程就完畢了,歡迎大家指正

相關文章