[Flutter]StatefulWidgetで操作による状態変化が行われる時の注意点

本記事は[Flutter #3 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/flutter-3)の18日目の記事です。

こんにちは。
この半年は勉強して来た**Flutter**を思いっきり実務で利用できた時間でした。
この前に無事アプリをリリースできましたので、色んな課題を乗り越えながら積む重ねて来たノートをそろそろ整理してアウトプットしようと思います。
どうぞよろしくお願いします。

# 概要
Flutterには様々な役割を持っている[Widget class](https://api.flutter.dev/flutter/widgets/Widget-class.html)があり、アプリの9割以上がWidgetで構成されています。
まるでLEGOのようにWidgetをはめ込む方式のため、後々混乱をなくすためには画面設計が終わってから以下のようなWidget Treeを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/b6dd015f-7087-d3b1-4d2e-c08ee08ceb05.png)
<出著:[flutter.dev](https://flutter.dev/docs/development/ui/layout)> 

また、Widgetは大きく 状態を持たない静的な **StatelessWidget** と 状態を持って動的な **StatefulWidget** で2種類に分けられています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/edbc49fd-a989-592e-edf2-490e270d7583.png)
<出著:[flutterclutter.dev](https://www.flutterclutter.dev/flutter/basics/statelesswidget-vs-statefulwidget/2020/1195/)>  

今回の内容は StatefulWidget クラスを生成して利用する時、画面の移動による別途の対応が必要だった話をしたいと思います。

# 例のアプリ実装
話の前に例として以下の仕様をもっているアプリを構築するんだとしましょう。

### 仕様
```text

1. REST APIで取得したデータの一覧がある。
2. 一覧データは2種類があり、種類ごとにタブで別れている。
3. 一覧データはOverscrolls될시 Refresh と Loadされる。
4. 他の画面からこの一覧画面に戻った時はリストデータが最新で更新された状態になる。
```
上で申し上げた仕様は 一般的で単純な 実装内容でございます。
まず、実装コードと動作は以下のとおりです。
※ 実際話たい内容とは少し離れておりましてけっこう省略します。

### 設計
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/f10e8e83-122c-6a4f-269f-95eb0937d36a.png)

### 実装

##### ● ホームスクリーンWidgetClass

```dart

// package:hoge/screens/home_screen.dart

// 一覧WidgetClassをインポート
import 'package:hoge/widgets/index_widget.dart';
// A,BデータモデルClassをインポート
import 'package:hoge/model/a.dart';
import 'package:hoge/model/b.dart';
// ...

// タブClass定義
class Tab {
  const Tab({this.title, this.icon, this.type});

  final String title;
  final IconData icon;
  final TabTypeValueEnum typec

  bool isAIndexTab() {
    return this.type == TabTypeValueEnum.INDEX_A_ACTIVATED;
  }

  bool isBIndexTab() {
    return this.type == TabTypeValueEnum.INDEX_B_ACTIVATED;
  }
}

// タブ種別リスト定義
const List<Tab> tabChoices = const <Tab>[
  const Tab(
      title: '一覧A',
      icon: FontAwesomeIcons.cookie,
      type: TabTypeValueEnum.INDEX_A_ACTIVATED),
  const Tab(
      title: '一覧B',
      icon: Icons.content_paste,
      type: TabTypeValueEnum.INDEX_B_ACTIVATED),
];

// ...

// ホームスクリーンWidgetClass
class HomeScreen extends StatefulWidget {
  // ...

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

// ホームスクリーンステータス
class _HomeScreenState extends State<HomeScreen> {
  TabController _tabController;
  TabTypeValue _currentTabIndex = TabTypeValue.MESSAGE_INDEX_ACTIVATED;
  // ...

  @override
  Widget build(BuildContext context) {

    return DefaultTabController(
      length: choices.length,
      child: Scaffold(
        // ...
        appBar: AppBar(
          actions: <Widget>[
            GestureDetector(
              onTap: () async {
                Navigator.pushNamed(context, OTHER_SCREEN);
              },
              child: Container(
                color: const Color(0x00FFD336),
                padding: const EdgeInsets.only(left: 0.0, right: 15.0),
                child: const Icon(Icons.more_vert, color: Color(0xBB000000)),
              ),
            ),
          ],
          // AppBarにTabBarプロパティを追加
          bottom: TabBar(
            controller: this._tabController,
            indicatorColor: const Color(0xFFF17E1C),
            indicator: const UnderlineTabIndicator(
              borderSide: BorderSide(
                color: Color(0xFFF17E1C),
                width: 5.0,
              ),
            ),
            labelColor: Colors.black,
            unselectedLabelColor: Colors.grey,
            // タブをタッチして切り替える
            onTap: (index) {
              setState(() {
                if (index == 0) {
                  this._currentTabIndex = TabTypeValueEnum.INDEX_A_ACTIVATED;
                } else {
                  this._currentTabIndex = TabTypeValueEnum.INDEX_B_ACTIVATED;
                }
              });
            },
            // タブリストを回してタブを表示
            tabs: tabChoices.map((Tab tabChoice) {
              return Padding(
                padding: const EdgeInsets.all(15.0),
                child: Text(
                  tabChoice.title,
                  style: const TextStyle(
                    fontSize: 18.0,
                    fontFamily: 'MPLUSRounded1c-Medium',
                    locale: Locale('ja'),
                  ),
                ),
              );
            }).toList(),
          ),
          actionsIconTheme:
              const IconThemeData(size: 26.0, color: Colors.black87),
        ),
        // ...
        body: Container(
          // ...
          // タブの中身を表示
          child: TabBarView(
            controller: _tabController,
            children: tabChoices.map((Tab tabChoice) {
              // タブに合わせて Aデータ、Bデータの 一覧Widgetをそれぞれリターン
              if (tabChoice.isAIndexTab()) {
                return IndexWidget(
                  model: AModel,
                );
              } else {
                return IndexWidget(
                  model: BModel,
                );
              }
            }).toList(),
          )
          // ...
        ),
  }
  // ...

}
```

##### ● 一覧WidgetClass

```dart

// package:hoge/widgets/index_widget.dart

// 一覧の各レコード になるA, BデータカードWidgetをインポート
import 'package:hoge/widgets/a_card_widget.dart';
import 'package:hoge/widgets/b_card_widget.dart';
// モデル系の親Classと A,BデータモデルClassをインポート
import 'package:hoge/model/model.dart';
import 'package:hoge/model/a.dart';
import 'package:hoge/model/b.dart';
// エンティティー系の親Classと A,BデータエンティティーClassをインポート
import 'package:hoge/entities/entity.dart';
import 'package:hoge/entities/a.dart';
import 'package:hoge/entities/b.dart';
// ...

const START_INDEX_COUNT = 5;

// 一覧WidgetClass
class IndexWidget extends StatefulWidget {
  IndexWidget({@required this.model});
  Model model; // A or Bの データモデル
  // ...

  @override
  _IndexWidgetState createState() => new _IndexWidgetState();
}

// 一覧ステータス
class _IndexWidgetState extends State<IndexWidget> {
  StreamController _dataController;
  RefreshController _refreshController =
      RefreshController(initialRefresh: false);
  int _listCountLevel = 1;

  @override
  void initState() {
    super.initState();
    this._dataController = new StreamController();
    // ...

    this._onRefresh();
  }

  // ...

  // SmartRefresher.onRefresh(上端)
  void _onRefresh() async {
    // データモデルで最新の一覧データで取得 (中身は REST APIでデータ取得)
    await widget.model.getIndexData(
      count: START_INDEX_COUNT * this._listCountLevel,
    ).then((dataList) async {
      this._dataController.add(dataList);
      this._refreshController.refreshCompleted();
    });
  }

  // SmartRefresher.onLoading(下端)
  void _onLoading() async {
    this._listCountLevel++;

    // データモデルで追加分を載せた一覧データで取得 (中身は REST APIでデータ取得)
    await widget.model.getIndexData(
      count: START_INDEX_COUNT * this._listCountLevel,
    ).then((dataList) async {
      this._dataController.add(dataList);
      this._refreshController.loadComplete();
    });
  }

  @override
  Widget build(BuildContext context) {

    return StreamBuilder<Object>(
      stream: this._dataController.stream,
      builder: (BuildContext context, AsyncSnapshot snapshot) {
        // ...

        if (snapshot.hasData) {
          return SmartRefresher(
            enablePullDown: true,
            enablePullUp: !this._loadStopFlg,
            // ...
            // 上端onRefreshのスタイル指定
            header: const ClassicHeader(
              refreshStyle: RefreshStyle.UnFollow,
            ),
            // 下端onLoadingのスタイル指定
            footer: const ClassicFooter(
              loadStyle: LoadStyle.ShowAlways,
            ),
            // ...
            controller: this._refreshController,
            onRefresh: this._onRefresh,
            onLoading: this._onLoading,
            // ...
            // 一覧データを表示
            child: ListView.builder(
              shrinkWrap: false,
              physics: const AlwaysScrollableScrollPhysics(),
              itemBuilder: (BuildContext context, index) {
                Entity entity = snapshot.data[index];

                return Row(
                  // ...
                  children: <Widget>[
                    SizedBox(
                      // ...
                      child: Container(
                        // ...
                        child: GestureDetector(
                          onTap: () => {this._goToViewScreen(entity)}, // 詳細画面に移動
                          child: entity.getCardWidget(), // A or Bデータのentityで設定されたゲッター（return CardWidget）
                          // ...
                        ),
                      ),
                    ),
                    // ...

                  ],
                );
              },
              itemCount: snapshot.data.length,
            ),
          );
        }
      }
    );
  }
}
```

##### ● AデータカードWidgetClass

```dart

// package:hoge/widgets/a_card_widget.dart

// AデータエンティティーClassをインポート
import 'package:hoge/entities/a.dart';
// ...

// AデータカードWidgetClass
class ADataCardWidget extends StatelessWidget {
  ADataCardWidget({@required this.entity});
  final AEntity entity;
  // ...

  @override
  Widget build(BuildContext context) {

    // カードWidgetにデータの内容を表示
    return Card(
      // ...
      child: Column(
        // ...
        children: <Widget>[
          child: Text(
            widget.entity.getTitle(),
            style: const TextStyle(
              fontSize: 17.0,
              fontFamily: 'MPLUSRounded1c-Regular',
              locale: Locale('ja'),
            ),
          ),
          // ...
        ],
      ),
    );
  }
}
```

##### ● 画面ルーティング定義

```dart

package:hoge/config/route.dart

import 'package:flutter/material.dart';

import 'package:hoge/screens/index_screen.dart';
import 'package:hoge/screens/other_screen.dart';

//...

// onGenerateRoute
Function generateRoute = (RouteSettings settings) {
  Map arg = settings.arguments;
  var routes = <String, WidgetBuilder>{
    INDEX_SCREEN: (context) => LoginFlowMenuScreen(),
    OTHER_SCREEN: (context) => OhterScreen(),
  };
  WidgetBuilder builder = routes[settings.name];
  return MaterialPageRoute(builder: (ctx) => builder(ctx));
};
```

### 画面
![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/ecece28e-84ef-2ad9-828b-01c2d1ddd209.gif)

# 画面及びタブ移動による課題及び対応
上で申し上げた例だけでは画面及びタブ移動をする時に想定異常の動作があるためその課題と解決の内容を話します。
（あ、すでにご存知なかたは 「その処理を入れてないから当然でしょうー」と思うかもしれません！笑）
まず、その異常動作は以下の通りです。
※ サーバ側で新しいデータが追加された時、そのタイミングを皆さんと一緒に確認するため、FCMでDialogが登場するようにしておきました。

### 課題

#### ● 課題①. 他の画面から戻って来ると一覧データが新しく更新されてない
![b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/cf8c2583-454f-3602-1e6d-22fd43ceaaf9.gif)

上の動作のように他の画面にいる時、`Vitamalz`というデータがサーバ側で追加されましたが、戻ってくると一覧データには`Vitamalz`がまだありません。
つまり、他の画面に移動する前のデータがそのまま見えてしまいますね。
また、`onRefresh`で新しくデータを更新すると 新しい`Vitamalz`というデータが一覧に登場します。

#### ● 課題②. 現在のタブで一覧データを更新した後に他のタブを行って来ると先の更新前の古い一覧データが見える
![c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/5e095c77-8709-94cc-e367-3e0f02f2031e.gif)

上の動作のようにサーバ側で`Heineken`というデータが追加され、現在のタブから`onRefresh`でデータを更新後、他のタブに行って来ると更新前の古いデータが見えますね。

### 原因及び解決

#### ● 課題①の原因

原因は単純です。
まず、Flutterで他の画面に移動や戻る時に利用される[Navigator](https://api.flutter.dev/flutter/widgets/Navigator-class.html)は 画面のルーティングをデータ構造Stackようにビルドされた画面を積み重ねる方式を提供するクラスです。
Navigatorは画面に移動する`push()`と画面に戻る`pop()` のメソッドがよく利用され、以下のように`push()`で新しい画面を下から載せて、`pop()`で上から引き抜ける単純な**Stack方式**で利用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108364/d9c8068d-1b2e-09d8-fb63-3731ec23bbec.png)
<出著:[flutter.ctrnost.com](https://flutter.ctrnost.com/basic/routing/)>  

他にも目的によって `pushNamedAndRemoveUntil()` や `popUnti()` など色んなメソッドが提供されます。

それでは原因の話に戻ると、新しい画面に移動する`Navigator.push()`の場合は新しく画面をビルドした後に載せる反面、前の画面に戻る`Navigator.pop()`は単純に最上にある画面を引き抜くだけで、すぐ下にある画面はすでにビルドが終わった画面になります。
それで前の画面のWidgetの状態(一覧データ)がそのまま見えることですね。

解決するためには`Navigator.pop()`で前の画面に戻った時は前の画面の状態を新しく更新する必要があります。
それは`Navigator.push()`に`then`を付けてCallback関数でWidgetの状態(一覧データ)を更新する処理で可能になります。

```dart

// package:hoge/screens/home_screen.dart

//...

onTap: () async {
  Navigator.pushNamed(context, OTHER_SCREEN).then((value) {
    // 一覧データ更新 (下位のStatefulWidgetの状態を変更)
  });
},

//...
```

#### ● 課題②の原因

まず、Flutterの`StatefulWidget`は`setState()`で状態変更を行い、その時に再ビルドされます。
これはタブが変更される時も同じです。

そのためにWidget Tree上で、`上位Widget(HomeScreenWidget)`の内にある`下位Widget(IndexWidget)`の状態(一覧データ)が更新されても、**タブの変更によって**`上位Widget(HomeScreenWidget)`の状態が更新し再ビルドされ、内にある`下位Widget(IndexWidget)`が最初画面がビルドされる時の内容で再配置されることです。

これを解決するためには`下位Widget(IndexWidget)`で状態変更(一覧データのonRefreshまたはonLoading)が行わった場合、`上位Widget(HomeScreenWidget)`にその最新データを渡して再ビルドされても更新状態が維持されるようにデータを同期化することです。

```dart

// package:hoge/screens/home_screen.dart

//...

List _aDataList = List();

void _rememberChangedADataList(List axDataList) {
  setState(() {
    this._aDataList = axDataList;
  });
}

//...

if (tabChoice.isAIndexTab()) {
  return IndexWidget(
    model: AModel,
    updatedCallbackFn: this._rememberChangedADataList,
    initIndexData: this._aDataList,
  );
} else {

//...
```

```dart

// package:hoge/widgets/index_widget.dart

//...

  void _onRefresh() async {
    // データモデルで最新の一覧データで取得 (中身は REST APIでデータ取得)
    await widget.model.getIndexData(
      count: START_INDEX_COUNT * this._listCountLevel,
    ).then((dataList) async {
      this._dataController.add(dataList);
      this._refreshController.refreshCompleted();
      widget.updatedCallbackFn(dataList);
    });
  }

//...
```

# まとめ

本内容で話した解決方法以外にも、データの種類や仕様に応じて、他のベスト的な解決方法があるかもしれません。 例えば Navigatorに Navigator ObserverやRoute Observerを定義してローディングに対する各種Callback関数を定義したり、グローバル系列のデータでを利用したり、チャットアプリで主に使用されるStreamデータのlistenを利用したり など様々な解決選択肢がありそうですね。

StatefulWidgetクラスを定義して使用する場合、現在の画面だけでなく、複数の画面Widgetのルーティングや状態変更による再ビルドされるタイミングを十分気をつけてアプリを実装した方が良いと思います。

ありがとうございます。