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

背景

PageView + BottomNavigationBar 或者 TabBarView + TabBar 的时候大家会发现当切换到另一页面的时候, 前一个页面就会被销毁, 再返回前一页时, 页面会被重建, 随之数据会重新加载, 控件会重新渲染 带来了极不好的用户体验, 跟原生的Pager 显示的效果不太一样。

解决方案

1. 官方推荐:AutomaticKeepAliveClientMixin

由于TabBarView内部也是用的是PageView, 因此两者的解决方式相同. 下面以PageView为例

//关键是继承 AutomaticKeepAliveClientMixin
class _Test6PageState extends State<Test6Page> with AutomaticKeepAliveClientMixin {
  @override
  void initState() {
    super.initState();
    print('initState');
  }
 
  @override
  void dispose() {
    print('dispose');
    super.dispose();
  }
 
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: widget.data.map((n) {
        return ListTile(
          title: Text("第${widget.pageIndex}页的第$n个条目"),
        );
      }).toList(),
    );
  }
 
 //方法返回true
  @override
  bool get wantKeepAlive => true;

这样使用这个页面作为 pagerView的child的时候回自动保存状态

详细的例子参考:

Flutter PageView 自由控制状态保持_一叶飘舟的博客-CSDN博客

2. 替换PageView 使用 IndexedStack
IndexedStack 继承 至 Stack,可以根据indexed来决定显示哪个child

 IndexedStack(
  index: currentIndex,
  children: bodyList,
 ));

缺点是:

  • 第一次加载时便实例化了所有的子页面State,因此比较适合固定页面的布局
  • 无法像pagerView一样通过手势左右滑动

3. 不推荐: 使用 Offstage/Visible + stack 手动切换显示。

这个其实就是 indexedstack内部的实现方式,顺便列下。

Stack(
   children: [
   Offstage(
    offstage: currentIndex != 0,
    child: bodyList[0],
   ),
   Offstage(
    offstage: currentIndex != 1,
    child: bodyList[1],
   ),
   Offstage(
    offstage: currentIndex != 2,
    child: bodyList[2],
   ),
   )

4. 使用PageStorage在页面切换时保存状态

有点像 Android里面的 saveInstanceState. 自己手动保存数据,手动恢复数据

4.1 创建widget时指定key

class MyApp extends StatelessWidget {
  final List<TabInfo> _tabs = [
    TabInfo(
      "FIRST", 
      Page1(key: PageStorageKey<String>("key_Page1")) // 指定key
    ), 
    TabInfo("SECOND", Page2()),
    TabInfo("THIRD", Page3()),
  ];

4.2 保存state

IconButton(
                icon: Icon(Icons.remove, size: 32.0),
                onPressed: () {
                  setState(() {
                    _params.counter1--;
                  });
                  PageStorage.of(context).writeState(context, _params);
                },


4.3 读取state

重写didChangeDependencies,通过readState 恢复state

class _Page1State extends State<Page1> {
  Page1Params _params;

  @override
  void didChangeDependencies() { //重写此方法
    Page1Params p = PageStorage.of(context).readState(context);
    if (p != null) {
      _params = p;
    } else {
      _params = Page1Params();
    }
    super.didChangeDependencies();
  }


通过 PageStorage 这种方式重新进入页面,页面其实是重新开始build,但是会根据 didChangeDependencies 里面的数据来初始化

思考:
为什么Flutter提供的PagerView 默认不实现 保存状态,而要通过 AutomaticKeepAliveClientMixin 这类来处理呢?

Flutter中为了节约内存不会保存widget的状态,widget都是临时变量。当我们使用TabBar,TabBarView是我们就会发现,切换tab后再重新切换回上一页面,这时候tab会重新加载重新创建,体验很不友好。Flutter出于自己的设计考虑并没有延续android的ViewPager这样的缓存页面设计,毕竟控件两端都要开发,目前还在beta版本有很多设计还不够完善,但是设计的拓展性没得说,flutter还是为我们提供了解决办法。我们可以强制widget不显示情况下保留状态,下回再加载时就不用重新创建了。


AutomaticKeepAliveClientMixin

AutomaticKeepAliveClientMixin 是一个抽象状态,使用也很简单,我们只需要用我们自己的状态继承这个抽象状态,并实现 wantKeepAlive 方法即可。

继承这个状态后,widget在不显示之后也不会被销毁仍然保存在内存中,所以慎重使用这个方法。


可滚动组件子项缓存

首先回想一下,在介绍 ListView 时,有一个addAutomaticKeepAlives 属性我们并没有介绍,如果addAutomaticKeepAlives 为 true,则 ListView 会为每一个列表项添加一个 AutomaticKeepAlive 父组件。虽然 PageView 的默认构造函数和 PageView.builder 构造函数中没有该参数,但它们最终都会生成一个 SliverChildDelegate 来负责列表项的按需加载,而在 SliverChildDelegate 中每当列表项构建完成后,SliverChildDelegate 都会为其添加一个 AutomaticKeepAlive 父组件。下面我们就先介绍一下 AutomaticKeepAlive 组件。

#6.8.1 AutomaticKeepAlive

AutomaticKeepAlive 的组件的主要作用是将列表项的根 RenderObject 的 keepAlive 按需自动标记 为 true 或 false。为了方便叙述,我们可以认为根 RenderObject 对应的组件就是列表项的根 Widget,代表整个列表项组件,同时我们将列表组件的 Viewport区域 + cacheExtent(预渲染区域)称为加载区域 :

  1. 当 keepAlive 标记为 false 时,如果列表项滑出加载区域时,列表组件将会被销毁。
  2. 当 keepAlive 标记为 true 时,当列表项滑出加载区域后,Viewport 会将列表组件缓存起来;当列表项进入加载区域时,Viewport 从先从缓存中查找是否已经缓存,如果有则直接复用,如果没有则重新创建列表项。

那么 AutomaticKeepAlive 什么时候会将列表项的 keepAlive 标记为 true 或 false 呢?答案是开发者说了算!Flutter 中实现了一套类似 C/S 的机制,AutomaticKeepAlive 就类似一个 Server,它的子组件可以是 Client,这样子组件想改变是否需要缓存的状态时就向 AutomaticKeepAlive 发一个通知消息(KeepAliveNotification),AutomaticKeepAlive 收到消息后会去更改 keepAlive 的状态,如果有必要同时做一些资源清理的工作(比如 keepAlive 从 true 变为 false 时,要释放缓存)。

我们基于上一节 PageView 示例,实现页面缓存,根据上面的描述实现思路就很简单了:让Page 页变成一个 AutomaticKeepAlive Client 即可。为了便于开发者实现,Flutter 提供了一个 AutomaticKeepAliveClientMixin ,我们只需要让 PageState 混入这个 mixin,且同时添加一些必要操作即可:

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {

  @override
  Widget build(BuildContext context) {
    super.build(context); // 必须调用
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }

  @override
  bool get wantKeepAlive => true; // 是否需要缓存
}

代码很简单,我们只需要提供一个 wantKeepAlive,它会表示 AutomaticKeepAlive 是否需要缓存当前列表项;另外我们必须在 build 方法中调用一下 super.build(context),该方法实现在 AutomaticKeepAliveClientMixin 中,功能就是根据当前 wantKeepAlive 的值给 AutomaticKeepAlive 发送消息,AutomaticKeepAlive 收到消息后就会开始工作,如下图所示:

现在我们重新运行一下示例,发现每个 Page 页只会 build 一次,缓存成功了。

需要注意,如果我们采用 PageView.custom 构建页面时没有给列表项包装 AutomaticKeepAlive 父组件,则上述方案不能正常工作,因为此时Client 发出消息后,找不到 Server,404 了,😀。

#6.8.2 KeepAliveWrapper

虽然我们可以通过 AutomaticKeepAliveClientMixin 快速的实现页面缓存功能,但是通过混入的方式实现不是很优雅,因为必须更改 Page 的代码,有侵入性,这就导致不是很灵活,比如一个组件能同时在列表中和列表外使用,为了在列表中缓存它,则我们必须实现两份。为了解决这个问题,笔者封装了一个 KeepAliveWrapper 组件,如果哪个列表项需要缓存,只需要使用 KeepAliveWrapper 包裹一下它即可。

@override
Widget build(BuildContext context) {
  var children = <Widget>[];
  for (int i = 0; i < 6; ++i) {
    //只需要用 KeepAliveWrapper 包装一下即可
    children.add(KeepAliveWrapper(child:Page( text: '$i'));
  }
  return PageView(children: children);
}

下面是 KeepAliveWrapper 的实现源码:

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;

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

class _KeepAliveWrapperState extends State<KeepAliveWrapper>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }

  @override
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if(oldWidget.keepAlive != widget.keepAlive) {
      // keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  bool get wantKeepAlive => widget.keepAlive;
}

下面我们再在 ListView 中测一下:

class KeepAliveTest extends StatelessWidget {
  const KeepAliveTest({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(itemBuilder: (_, index) {
      return KeepAliveWrapper(
        // 为 true 后会缓存所有的列表项,列表项将不会销毁。
        // 为 false 时,列表项滑出预加载区域后将会别销毁。
        // 使用时一定要注意是否必要,因为对所有列表项都缓存的会导致更多的内存消耗
        keepAlive: true,
        child: ListItem(index: index),
      );
    });
  }
}

class ListItem extends StatefulWidget {
  const ListItem({Key? key, required this.index}) : super(key: key);
  final int index;

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

class _ListItemState extends State<ListItem> {
  @override
  Widget build(BuildContext context) {
    return ListTile(title: Text('${widget.index}'));
  }

  @override
  void dispose() {
    print('dispose ${widget.index}');
    super.dispose();
  }
}

因为每一个列表项都被缓存了,所以运行后滑动列表预期日志面板不会有任何日志,如图所示:

好我们预期一致,日志面板没有日志。如果我们将 keepAlive 设为 false,则当列表项滑出预渲染区域后则会销毁,日志面板将有输出,如图示:

可见我们封装的 KeepAliveWrapper 能够正常工作,笔者将 KeepAliveWrapper 添加到了 flukit 组件库,如果读者需要可以在 flukit 组件库中找到它。

来自:《Flutter实战·第二版》

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Flutter中使用NestedScrollView 嵌套SliverAppBar,同时在body中使用TabBarView时,可能会遇到头部不滑动的问题,这是由于TabBarView默认会占据整个屏幕,并覆盖在SliverAppBar下方,导致SliverAppBar无法随着用户向下滑动而实现滑动效果。 为了解决这个问题,你可以使用CustomScrollView作为NestedScrollView的body,并将TabBarView作为CustomScrollView的slivers属性,这样TabBarView就会作为一个Sliver来嵌套在SliverAppBar下方,从而实现头部和列表的联动滑动效果。 例如,以下是一个嵌套SliverAppBar并使用TabBarView的示例代码: ``` DefaultTabController( length: 2, child: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverAppBar( floating: true, snap: true, title: Text('NestedScrollView Demo'), expandedHeight: 200.0, flexibleSpace: FlexibleSpaceBar( background: Image.network( 'https://picsum.photos/200/300', fit: BoxFit.cover, ), ), bottom: TabBar( tabs: <Widget>[ Tab(text: 'Tab 1'), Tab(text: 'Tab 2'), ], ), ), ]; }, body: CustomScrollView( slivers: <Widget>[ SliverFillRemaining( child: TabBarView( children: <Widget>[ ListView.builder( itemBuilder: (BuildContext context, int index) { return ListTile( title: Text('Tab 1 - Item $index'), ); }, itemCount: 50, ), ListView.builder( itemBuilder: (BuildContext context, int index) { return ListTile( title: Text('Tab 2 - Item $index'), ); }, itemCount: 50, ), ], ), ), ], ), ), ); ``` 在这个示例中,我们使用CustomScrollView作为NestedScrollView的body,并将TabBarView作为CustomScrollView的slivers属性,从而实现了头部和列表的联动滑动效果。同时,我们还使用了DefaultTabController来管理TabBarTabBarView的联动效果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值