Futureが失敗したときにSnackBarでエラーを表示する（ScopedModelでの場合）

# 前置き

Dartでは、ネットワーク通信などの結果は `Future` で返ってきます。

ネットワーク通信はときどき失敗するので、そのときはユーザーにエラーを表示しつつ、再読込の手順を案内しなくてはなりません。
MaterialDesignの文脈では、そうしたエラーの報告と再読込のボタン表示に[SnackBar](https://material.io/design/components/snackbars.html)を使用します。

Flutterでも `SnackBar` は標準のWidgetとして準備されているので、これを使用しない手はありません。

# やりたいこと

通信に失敗したとき、SnackBarでエラー表示をしたい

こんな感じで。
![Material Designのサンプル画像](https://storage.googleapis.com/spec-host-backup/mio-design%2Fassets%2F1hJm24PlwoTxWrIMhkPE0RV2Q-ahyQVZq%2Fsnackbars-container-do.png)


# プロジェクトのアーキテクチャ

アーキテクチャはScopedModelを採用しています。

ScopedModelについて、詳しくは @hayassh さんの記事が詳しいのでこちらをご参照ください。

* [Scoped Modelの基本的な使い方（Flutter）](https://qiita.com/hayassh/items/690fa0d6528e056617b5)

Flutterで広く採用されているアーキテクチャはBLoCだと思うので、違いが気になる方は @kabochapo さんの記事がわかりやすいのでこちらをご参照ください。

* [BLoCパターンの問題点とScoped Modelとの比較](https://qiita.com/kabochapo/items/2b992cc00e9f464c1ea9)


今回ScopedModelを採用しているのは、単に私がBLoCで設計したことがないから、というだけの理由です。
BloCはいつかやるかも。やらないかも。

# 問題点

`Completable` を使用すると以下の状態を取り扱えます。

* 読み込み中（`Completable#isComplete`）
* 成功（`Future#then`）
* 失敗（`Future#then`）

なので `Completable` を使って `future` をModelから露出させ、そのFutureの内容を `FutureBuilder` で取り出して使用する…ということを目論みました。
が。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">FutureBuilderでAsyncSnapshot#hasErrorがtrueだったときにScaffold#showSnackBarを使うと例外が飛ぶ（showSnackBarがState#setStateを使っているので、build中にsetStateするな、という例外が出る）のでFutureBuilderだけでFutureを見張ってSnackBarでのエラー表示までするのは無理。</p>&mdash; 菊池紘 (@kikuchy) <a href="https://twitter.com/kikuchy/status/1115514074945601537?ref_src=twsrc%5Etfw">2019年4月9日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

コードに落とすとこう↓

```dart
FutureBuilder<String>(
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.done) {
      if (snapshot.hasError) {
        final snackBar = ...;
        // ここで例外が出る
        Scaffold.of(context).showSnackBar(snackBar);
        return Text("エラーだよ");
      } else {
        return Text("成功時の表示だよ");
      }
    } else {
      return CircularProgressIndicator();
    }
})
```

実行するとこう↓

![setState() or markNeedsBuild() called during build.This Scaffold widget cannot be marked as needing to build because the framework is already in the process of building widgets. A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.](https://qiita-image-store.s3.amazonaws.com/0/41896/4852d92c-f37e-4936-eab0-59de52d90364.png)

[`ScaffoldState#showSnackBar()`](https://docs.flutter.io/flutter/material/ScaffoldState/showSnackBar.html) は内部で `State#setState()` を呼んでいるため、build内でこれを呼び出すと例外が起こるのです。



# 解決法

Futureを直に使うとうまくいかないので、読み込みの状態管理を `Future` から取り上げて、Model側で管理するようにします。

また、[scoped_model](https://pub.dartlang.org/packages/scoped_model)のModelは[Listenable](https://docs.flutter.io/flutter/foundation/Listenable-class.html)を継承しているので、listenerを仕掛けてエラーが起きていればSnackBarを表示するようにします。
buildさえ走っていなければ、 `showSnackBar()` は使い放題です。

## 状態定義

Modelが取りうる状態を定義しておきます。

今までAPI経由で取得した値を保持し続けたいものとして、最新のデータを `lastData` として持つことにしました。

```dart
/// 状態のベースクラス
///
/// 早く直和型が欲しい
abstract class LoadModelStatus {
  LoadModelStatus(this.lastData);

  final String lastData;
}

/// 読み込み中
class LoadModelStatusLoading extends LoadModelStatus {
  LoadModelStatusLoading(String lastData) : super(lastData);
}

/// 読み込み成功
class LoadModelStatusSuccess extends LoadModelStatus {
  LoadModelStatusSuccess(String lastData) : super(lastData);
}

/// 読み込み失敗。一応エラーの詳細も持てるようにする。
class LoadModelStatusFailure extends LoadModelStatus {
  LoadModelStatusFailure(String lastData, this.error) : super(lastData);
  final Object error;
}
```

## モデル

[scoped_model](https://pub.dartlang.org/packages/scoped_model)を使用します。

状態はメンバに保持し、外からの書き換えは許しません。

モデルは読み込みのリクエストだけを受け付けます。
すでに読み込み中の時は何もしないようにすれば、リクエストを何度も投げずに済むためです。

```dart
/// APIと画面表示をつなぐモデル
class LoadModel extends Model {
  LoadModelStatus _status = LoadModelStatusSuccess("Initial state");
  LoadModelStatus get status => _status;

  Future<void> requestLoad() async {
    // すでに読み込み中だったらリクエストを無視。
    if (_status is LoadModelStatusLoading) return;

    // 状態を読み込み中に変更して、変更されたことを通知
    _status = LoadModelStatusLoading(_status.lastData);
    notifyListeners();
    try {
      // APIコールの代わり。ランダムに成功したり失敗したりする
      final data = await Future.delayed(
          Duration(milliseconds: 300),
          () => Random().nextBool()
              ? "Success!!"
              : throw Exception("Failure..."));
      // 成功した、という状態に更新
      _status = LoadModelStatusSuccess(_status.lastData + data);
    } catch (e) {
      // 失敗した、という状態に更新
      _status = LoadModelStatusFailure(_status.lastData, e);
    }
    notifyListeners();
  }
}
```

## エラー表示

StatefulWidgetを使用して画面を用意します。
ScopedModelパターンなのでStatelessWidgetを使用する手もあるのですが、 `Scaffold.of()` を行儀よく使用するためには [`State#context`](https://docs.flutter.io/flutter/widgets/State/context.html)を使用する必要があるので、こうなりました。

`State#context` の存在は、 @ryunosukeheaven さんの[記事](https://qiita.com/ryunosukeheaven/items/30e9769fcfde5f0fc096)を読んで知りました。感謝！


<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Scaffoldの機能を同一Scaffold内で使いたい時は、Scaffoldを持つWidgetと、body以下のWidgetを分割して親子構造にするとかよくやります。SnackBarだとどうか分からないですが...<br><br>以下は、Scaffold - PrimaryScrollControllerの例です。<a href="https://t.co/h391nJEzXF">https://t.co/h391nJEzXF</a><a href="https://t.co/UtLZ94DEOF">https://t.co/UtLZ94DEOF</a></p>&mdash; ヘブン🦌 (@heavenOSK) <a href="https://twitter.com/heavenOSK/status/1115530806187835393?ref_src=twsrc%5Etfw">2019年4月9日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


```dart
/// 画面部分のWidget。こいつがModelのインスタンスを抱える。
class LoadPage extends StatefulWidget {
  LoadPage({Key key, this.model}) : super(key: key);

  final LoadModel model;

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

/// 画面部分の状態を抱える君。
class _LoadPageState extends State<LoadPage> {
  /// エラー表示をするためのlistener
  void showSnackBarOnError() {
    // Smart castを効かせるため取り出しておく
    final status = widget.model.status;
    if (status is LoadModelStatusFailure) {
      final snackBar = SnackBar(
          content: Text("Failed to load: ${status.error}"),
          action: SnackBarAction(
              label: "Realod", onPressed: () => widget.model.requestLoad()));
      // GlobalKeyを使ってScaffoldを取り出す、ということをしなくて済んだ！！
      // この `context` は `State#context`。
      // `initState()` 呼び出し後なら好きなタイミングで使用できます。
      Scaffold.of(context).showSnackBar(snackBar);
    }
  }

  @override
  void initState() {
    super.initState();
    // Modelにlistenerをセット
    widget.model.addListener(showSnackBarOnError);
    // ついでに初回ロードする
    widget.model.requestLoad();
  }

  @override
  void dispose() {
    // 後始末しておく
    widget.model.removeListener(showSnackBarOnError);
    super.dispose();
  }
}
```

## 読み込み中と成功時表示

[scoped_model](https://pub.dartlang.org/packages/scoped_model)のReadmeのサンプルを参考に書きました。
それだけ。

```dart
  @override
  Widget build(BuildContext context) {
    // ScopedModelが必要
    return ScopedModel<LoadModel>(
        model: widget.model,
        child: Center(
          child: Column(
            children: <Widget>[
              // モデルが抱えているデータを表示。これは成功しても失敗しても表示する
              ScopedModelDescendant<LoadModel>(
                  builder: (context, child, model) =>
                      Text(model.status.lastData)),
              SizedBox(
                height: 16,
              ),
              // 読み込み中のときはボタンを隠してプログレスリングを表示する。
              // 成功と失敗のときはボタンを表示
              ScopedModelDescendant<LoadModel>(
                builder: (context, child, model) {
                  if (model.status is LoadModelStatusLoading) {
                    return CircularProgressIndicator();
                  } else {
                    return RaisedButton(
                        child: Text("Reload"),
                        onPressed: () => model.requestLoad());
                  }
                },
              ),
            ],
          ),
        ));
  }
```

## 結果

![読み込み結果が変化しているのがわかる](https://github.com/kikuchy/error-snackbar-scoped-model/raw/master/art/sample.gif)

エラー時にクラッシュしない！！ :tada: 
読み込み中であることがわかる表示！！ :confetti_ball: 
SnackBarからも再読込できる！！ :congratulations: 

[動作するサンプルのソースはこちら](https://github.com/kikuchy/error-snackbar-scoped-model/blob/master/lib/main.dart)