【Flutter】TabViewのページ切り替え時に再ビルドが走らないようにする【TabView】

# はじめに

最近Flutterでたくさん実装していて、TabViewを使う画面をこの間作った。

Androidみたいにキャッシュするページ数を設定できるAPIがあるかなと思ったんだけど、そんなこともなく、ページ切り替えの度に再ビルドが走って、なんだったらデータの取得も毎回行われていた。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474683/30812389-3805-5367-3c74-d4f0febb6357.gif" width="400"/>

ページ切り替え後に前のページが表示されていて、ちょっとしてから更新されているのがわかるね。
データがキャッシュされていないので、一度表示したページを再度表示した時も再ビルドが走っちゃっている感じがする。
実際のプロジェクトではImageなども使用しているので、もっとわかりやすく描画が遅れていた。

これではパフォーマンスもUXも良くないなと思い、ページ切り替えの際に画面のビルドが走らないようにしたよ。
今回はその時やったことをまとめた。

# 前提

今回はRiverpodを利用したMVVMアーキテクチャで実装しているよ。

## やること

今回の実装に必要なことは大きく分けて下記の2つ。

1. タブで表示するページのクラスにAutomaticKeepAliveClientMixinをwithする。
2. ViewModelのProviderで.familyを使う。

## 1.タブで表示するページのクラスにAutomaticKeepAliveClientMixinをwithする。

StackOverFlowで調べたところ、同じことを聞いてる人がいて、このやり方が紹介されていた。
やることはめちゃくちゃ簡単で、

これを

```dart
class HomeTabPage extends HookConsumerWidget {
  final int index;

  const HomeTabPage({Key? key, required this.index}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.read(homeTabViewModelNotifierProvider);
    final list = ref
        .watch(homeTabViewModelNotifierProvider.select((value) => value.list));

    useEffect(() {
      viewModel.fetchData(index);
      return;
    }, []);

    return list != null
        ? ListView.separated(
            itemCount: list.length,
            itemBuilder: (context, index) {
              return Container(
                  alignment: Alignment.centerLeft,
                  height: 56,
                  padding: EdgeInsets.symmetric(horizontal: 8),
                  child: Text(
                      list[index],
                    style: const TextStyle(
                      fontSize: 18
                    ),
                  )
              );
            },
            separatorBuilder: (context, index) {
              return const Divider(height: 1, color: Colors.black);
            },
          )
        : const SizedBox();
  }
}
```

こうするだけ。

```dart
class HomeTabPage extends StatefulHookConsumerWidget {
  final int index;

  const HomeTabPage({Key? key, required this.index}) : super(key: key);

  @override
  ConsumerState<ConsumerStatefulWidget> createState() => _HomeTabPageState();
}

class _HomeTabPageState extends ConsumerState<HomeTabPage>
    with AutomaticKeepAliveClientMixin {

  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final viewModel = ref.read(homeTabViewModelNotifierProvider);
    final list = ref
        .watch(homeTabViewModelNotifierProvider.select((value) => value.list));

    useEffect(() {
      viewModel.fetchData(widget.index);
      return;
    }, []);

    return list != null
        ? ListView.separated(
      itemCount: list.length,
      itemBuilder: (context, index) {
        return Container(
            alignment: Alignment.centerLeft,
            height: 56,
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Text(
              list[index],
              style: const TextStyle(
                  fontSize: 18
              ),
            )
        );
      },
      separatorBuilder: (context, index) {
        return const Divider(height: 1, color: Colors.black);
      },
    )
        : const SizedBox();
  }
}

```

StatelessだったHomeTabPageをStatefulにして、`with AutomaticKeepAliveClientMixin`を追加してあげるだけ。
簡単✨

## 2. ViewModelのProviderで.familyを使う。

1をやっただけだと「ページの状態を維持する」だけなので、こんな感じになる。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474683/c92cc346-5800-74a1-4643-856745edc93f.gif" width="400"/>

最後に表示したページのデータがそのまま表示されちゃっているね。
当然期待通りの動きじゃないので、直していきたい。

こうなっちゃってる原因は、ViewModelのインスタンスがタブのページ全てで共有されてしまっていることにある。

■ ページでViewModelを共有してしまっている例
![Untitled-2021-08-02-1456.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474683/0ef24d1b-28c6-69b8-849c-1587b1956aed.png)

本来なら、HomeTabPageごとにデータを保持しておきたいので、それぞれにViewModelを生成しておきたい。

■ ページごとにViewModelを生成している例

![Untitled-2021-08-02-1456.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474683/0118a7d3-3c13-ba52-5cac-aca29311caa0.png)

これをするために、Providerで.familyを使ってあげる。

これを
```dart
final homeTabViewModelNotifierProvider =
    ChangeNotifierProvider((ref) => HomeTabViewModel(ref.read));
```

こう。

```dart
final homeTabViewModelNotifierProvider =
    ChangeNotifierProvider.family<HomeTabViewModel, int>(
        (ref, id) => HomeTabViewModel(ref.read, id));
```

watchしている側も下記の通り修正してあげる。

これを

```dart
final viewModel = ref.read(homeTabViewModelNotifierProvider);
final list = ref
    .watch(homeTabViewModelNotifierProvider.select((value) => value.list));
```

こう。

```dart
final viewModel = ref.read(homeTabViewModelNotifierProvider(widget.index));
final list = ref.watch(homeTabViewModelNotifierProvider(widget.index)
    .select((value) => value.list));
```

これで、idごとにHomeTabViewModelのインスタンスが生成されるね！
完成〜🎉

# まとめ

TabViewのページ切り替え時に再ビルドが走らないようにするために必要な修正は下記の2点。

**1. タブで表示するページのクラスにAutomaticKeepAliveClientMixinをwithする。**
**2. ViewModelのProviderで.familyを使う。**


修正後の挙動はこんな感じ。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/474683/301e677e-564a-24b0-556e-abe7b6067512.gif" width="400"/>

ちゃんとデータがキャッシュされて、再ビルドされずに表示されているね👍
いい感じ！

今回のサンプルは[ここ](https://github.com/KKusumi/tab_view_sample)で公開しているので、わからなかったら見てみてね。
コミットごとに見ればどう修正すればいいかわかると思うわ🐶

おわり。

# 参考

- https://riverpod.dev/ja/docs/concepts/reading/
- https://stackoverflow.com/questions/49087703/preserving-state-between-tab-view-pages