Edited at

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


前置き

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

ネットワーク通信はときどき失敗するので、そのときはユーザーにエラーを表示しつつ、再読込の手順を案内しなくてはなりません。

MaterialDesignの文脈では、そうしたエラーの報告と再読込のボタン表示にSnackBarを使用します。

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


やりたいこと

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

こんな感じで。


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

アーキテクチャは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();
}
})

実行するとこう↓

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:

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