15
8

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-04-09

前置き

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

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

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

やりたいこと

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

こんな感じで。
Material Designのサンプル画像

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

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

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

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

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

問題点

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

  • 読み込み中(Completable#isComplete
  • 成功(Future#then
  • 失敗(Future#then

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

コードに落とすとこう↓

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.

ScaffoldState#showSnackBar() は内部で State#setState() を呼んでいるため、build内でこれを呼び出すと例外が起こるのです。

解決法

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

また、scoped_modelのModelはListenableを継承しているので、listenerを仕掛けてエラーが起きていればSnackBarを表示するようにします。
buildさえ走っていなければ、 showSnackBar() は使い放題です。

状態定義

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

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

/// 状態のベースクラス
///
/// 早く直和型が欲しい
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を使用します。

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

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

/// 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を使用する必要があるので、こうなりました。

State#context の存在は、 @ryunosukeheaven さんの記事を読んで知りました。感謝!

/// 画面部分の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のReadmeのサンプルを参考に書きました。
それだけ。

  @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());
                  }
                },
              ),
            ],
          ),
        ));
  }

結果

読み込み結果が変化しているのがわかる

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

動作するサンプルのソースはこちら

15
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
8