Flutter切换底部导航, 怎样保持子页面状态?

1,876 阅读5分钟

前言

产品设计时, 一款APP使用底部导航结构, 是司空见惯的, 闲鱼做为国内第一个全Flutter产品, 也使用的是底部导航结构。平常开发过程中, 开发者只要按照官方文档的要求, 使用底部导航的控件就可以轻松实现一个底部导航的APP。但是, 最近在浏览flutter论坛时, 发现一个关于底部导航切换, 如何保持子页面状态的问题? 笔者带着疑问, 一起来探究下。

问题复现

复现子页面重新创建

main.dart

class IndexStackApp extends StatefulWidget {
  const IndexStackApp({Key? key}) : super(key: key);

  @override
  _IndexStackAppState createState() => _IndexStackAppState();
}

class _IndexStackAppState extends State<IndexStackApp> {
  int _currentIndex = 0;

  final List<BottomNavigationBarItem> _bottomNavItems = [
    BottomNavigationBarItem(
      backgroundColor: Colors.blue,
      icon: Icon(Icons.home),
      title: Text("首页"),
    ),
    BottomNavigationBarItem(
      backgroundColor: Colors.green,
      icon: Icon(Icons.message),
      title: Text("消息"),
    ),
    BottomNavigationBarItem(
      backgroundColor: Colors.amber,
      icon: Icon(Icons.shopping_cart),
      title: Text("购物车"),
    ),
  ];

  final _pages = [
    APage(),
    BPage(),
    CPage(),
  ];

  @override
  Widget build(BuildContext context) {
    print('------main, ----build');
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        /// 1、这种方式, 得到的子widget每次都会重新build, 也就是重新绘制
        body: _pages[_currentIndex],
        bottomNavigationBar: BottomNavigationBar(
          items: _bottomNavItems,
          currentIndex: _currentIndex,
          onTap: (index) {
            setState(() {
              _currentIndex = index;
            });
          },
        ),
      ),
    );
  }
}

a.dart

import 'package:flutter/material.dart';

class APage extends StatefulWidget {
  const APage({Key? key}) : super(key: key);

  @override
  _APageState createState() => _APageState();
}

class _APageState extends State<APage> {

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    print('------a, initState');
  }

  @override
  Widget build(BuildContext context) {
    print('------a----build');
    return  Container(
      width: double.infinity,
      height: double.infinity,
      color: Colors.indigoAccent,
      child: Center(
        child: Text('aaaaaaaaaaaaaaaaaaaaaa'),
      )
    );
  }

  @override
  void deactivate() {
    // TODO: implement deactivate
    super.deactivate();
    print('------a----deactivate');
  }

  @override
  void dispose() {
    // TODO: implement dispose
    super.dispose();
    print('------a----dispose');
  }
}

b.dart

import 'package:flutter/material.dart';

class BPage extends StatefulWidget {
  const BPage({Key? key}) : super(key: key);

  @override
  _BPageState createState() => _BPageState();
}

class _BPageState extends State<BPage> {

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    print('------b, initState');
  }

  @override
  Widget build(BuildContext context) {
    print('------b----build');
    return  Container(
        width: double.infinity,
        height: double.infinity,
        color: Colors.amber,
        child: Center(
          child: Text('bbbbbbbbbbbbbbbbbbbbbbbbbbbb'),
        )
    );
  }

  @override
  void deactivate() {
    // TODO: implement deactivate
    super.deactivate();
    print('------b----deactivate');
  }

  @override
  void dispose() {
    // TODO: implement dispose
    super.dispose();
    print('------b----dispose');
  }
}

c.dart

import 'package:flutter/material.dart';

class CPage extends StatefulWidget {
  const CPage({Key? key}) : super(key: key);

  @override
  _CPageState createState() => _CPageState();
}

class _CPageState extends State<CPage> {

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    print('------c, initState');
  }

  @override
  Widget build(BuildContext context) {
    print('------c----build');
    return Container(
        width: double.infinity,
        height: double.infinity,
        color: Colors.indigoAccent,
        child: Center(
          child: Text('ccccccccccccccccccc'),
        )
    );
  }

  @override
  void deactivate() {
    // TODO: implement deactivate
    super.deactivate();
    print('------c----deactivate');
  }

  @override
  void dispose() {
    // TODO: implement dispose
    super.dispose();
    print('------c----dispose');
  }
}

上面的实现方式, body: _pages[_currentIndex]。每次根据下标_currentIndex从页面数组_pages取出对应的子页面时, 都会重新创建页面。

c.png

我们看下控制台的日志。

子widget保存状态.png

从日志可以看出, 程序启动后的顺序。

  • 会先在main.dart中根据下标_currentIndex = 0取出APage
  • APage会创建并执行initStatebuild方法。
  • 此时, 点击底部导航按钮购物车, 程序会先执行APagedeactivate使APage处于失活状态
  • 再执行CPageinitState新建CPage
  • 然后执行CPagebuild
  • 执行APagedispose, 销毁APage

解决方案

频繁新建子页面, 这样显然是有缺陷的, 因为会耗费性能。

IndexedStack

因为, 笔者用的是IndexedStack来实现底部导航的, 所以, 接下来, 我们一起看下。上面main.dartbuild方法可以改成如下实现方式。

class IndexStackApp extends StatefulWidget {
  const IndexStackApp({Key? key}) : super(key: key);

  @override
  _IndexStackAppState createState() => _IndexStackAppState();
}

class _IndexStackAppState extends State<IndexStackApp> {
  int _currentIndex = 0;

  final List<BottomNavigationBarItem> _bottomNavItems = [
    BottomNavigationBarItem(
      backgroundColor: Colors.blue,
      icon: Icon(Icons.home),
      title: Text("首页"),
    ),
    BottomNavigationBarItem(
      backgroundColor: Colors.green,
      icon: Icon(Icons.message),
      title: Text("消息"),
    ),
    BottomNavigationBarItem(
      backgroundColor: Colors.amber,
      icon: Icon(Icons.shopping_cart),
      title: Text("购物车"),
    ),
  ];

  final _pages = [
    APage(),
    BPage(),
    CPage(),
  ];

  @override
  Widget build(BuildContext context) {
    print('------main, ----build');
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        /// 2、这种方式, 得到的子widget不会每次重新build
        body: IndexedStack(
          index: _currentIndex,
          children: _pages,
        ),
        bottomNavigationBar: BottomNavigationBar(
          items: _bottomNavItems,
          currentIndex: _currentIndex,
          onTap: (index) {
            setState(() {
              _currentIndex = index;
            });
          },
        ),
      ),
    );
  }
}

使用IndexedStack实现底部导航, 点击购物车, 我们一起看看控制台日志。

c-.png

  • main.dart, 程序运行后, 会一次性全部加载APageBPageCPage三个子页面
  • 点击底部导航购物车, 程序只是执行了main.dartbuild方法, 并没有加载三个子页面的任何一个

由此可知, 通过IndexedStack组件, 点击底部导航, 根据下标index, 只展示对应的子页面即可, 继续保持子页面状态。

PageView

如果此时产品有了新的要求, 三个子页面, 可以滑动来回切换。那么, 滑动切换子页面, 子页面会不会出现重新绘制呢?如果重新绘制了, 有没有方案, 使子页面保持子页面状态呢?我们一起来看下。

main.dart的代码更新如下:

class _IndexStackAppState extends State<IndexStackApp> {
  int _currentIndex = 0;

  final List<BottomNavigationBarItem> _bottomNavItems = [
    BottomNavigationBarItem(
      backgroundColor: Colors.blue,
      icon: Icon(Icons.home),
      title: Text("首页"),
    ),
    BottomNavigationBarItem(
      backgroundColor: Colors.green,
      icon: Icon(Icons.message),
      title: Text("消息"),
    ),
    BottomNavigationBarItem(
      backgroundColor: Colors.amber,
      icon: Icon(Icons.shopping_cart),
      title: Text("购物车"),
    ),
  ];

  final _pages = [
    APage(),
    BPage(),
    CPage(),
  ];

  PageController _pageController = PageController();

  @override
  Widget build(BuildContext context) {
    print('------main, ----build');
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        /// 3、PageView方式, 滑动视图
        body: PageView(
          controller: _pageController,
          children: _pages,
          onPageChanged: (index){
            setState(() {
              _currentIndex = index;
            });
          },
        ),
        bottomNavigationBar: BottomNavigationBar(
          items: _bottomNavItems,
          currentIndex: _currentIndex,
          onTap: (index) {
            setState(() {
              _currentIndex = index;
            });
          },
        ),
      ),
    );
  }
}

运行程序, 并滑动子页面到BPage, 我们一起来看下控制台日志。

pageView.png

  • 程序运行, main.dart只加载APage
  • 滑动页面到BPage, 执行BPageinitStatebuild
  • 接着APage子页面deactivate失去活性, 并dispose被销毁
  • 再从子页面BPage滑动到子页面APage
  • APage重新initStatebuild新建页面
  • 然后, BPage子页面deactivate失去活性, 并dispose被销毁

从上面日志看, PageView每一次滑动子页面, 前一个页面就会被销毁, 并重新新建、绘制后一个子页面。循环往复。这并不是我们所期望的。这样显然是有缺陷的。那么, 有没有一种方案, 滑动子页面时, 可以保持子页面的状态呢?答案是有的。

AutomaticKeepAliveClientMixin

官方推荐在子页面的state类里面, 混合继承AutomaticKeepAliveClientMixin类就可以解决滑动页面, 子页面销毁的问题

import 'package:flutter/material.dart';

class APage extends StatefulWidget {
  const APage({Key? key}) : super(key: key);

  @override
  _APageState createState() => _APageState();
}

class _APageState extends State<APage> with AutomaticKeepAliveClientMixin {

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    print('------a, initState');
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    print('------a----build');
    return  Container(
      width: double.infinity,
      height: double.infinity,
      color: Colors.indigoAccent,
      child: Center(
        child: Text('aaaaaaaaaaaaaaaaaaaaaa'),
      )
    );
  }

  @override
  void deactivate() {
    // TODO: implement deactivate
    super.deactivate();
    print('------a----deactivate');
  }

  @override
  void dispose() {
    // TODO: implement dispose
    super.dispose();
    print('------a----dispose');
  }

  @override
  // TODO: implement wantKeepAlive
  bool get wantKeepAlive => true;
}

子页面混合继承AutomaticKeepAliveClientMixin, 并实现bool get wantKeepAlive => true;

控制台日志如下:

keepAlive.png

  • 程序运行, main.dart先加载APage
  • 滑动到BPage, 会加载BPage
  • 滑动CPage, 会加载CPage
  • 再滑动到APage, 会来到APage

每一次滑动子页面, 并不会销毁前一个页面。解决了切换底部导航, 保持子页面状态的问题。使用步骤如下:

  • State类混合继承 AutomaticKeepAliveClientMixin
  • 重写 bool get wantKeepAlive => true;
  • build方法中调用super.build(context);

参考资料

官网AutomaticKeepAliveClientMixin<​T extends StatefulWidget> mixin

Flutter填坑-如何保持底部导航栏页中子页面状态

Flutter: PageView/TabBarView等控件保存状态的问题解决方案

Flutter AutomaticKeepAliveClientMixin 无效的原因

Flutter移动电商实战-切换后页面状态的保持AutomaticKeepAliveClientMixin