前置き
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でAsyncSnapshot#hasErrorがtrueだったときにScaffold#showSnackBarを使うと例外が飛ぶ(showSnackBarがState#setStateを使っているので、build中にsetStateするな、という例外が出る)のでFutureBuilderだけでFutureを見張ってSnackBarでのエラー表示までするのは無理。
— 菊池紘 (@kikuchy) 2019年4月9日
コードに落とすとこう↓
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 さんの記事を読んで知りました。感謝!
Scaffoldの機能を同一Scaffold内で使いたい時は、Scaffoldを持つWidgetと、body以下のWidgetを分割して親子構造にするとかよくやります。SnackBarだとどうか分からないですが...
— ヘブン🦌 (@heavenOSK) 2019年4月9日
以下は、Scaffold - PrimaryScrollControllerの例です。https://t.co/h391nJEzXFhttps://t.co/UtLZ94DEOF
/// 画面部分の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());
}
},
),
],
),
));
}
結果
エラー時にクラッシュしない!!
読み込み中であることがわかる表示!!
SnackBarからも再読込できる!!