[Flutter] ウィジェット構築中のエラーをいい感じに処理する

# 概要

[スポイトツールウィジェット](https://pub.dev/packages/simple_eye_dropper)を作った際に、画像が壊れていた場合の処理はウィジェットの呼び出し元に任せたいなと思ったのですが、どうすればいいのかちょっと悩んだのでメモを残しておきます。

結論から書くと、エラーオブジェクトを処理してウィジェットを返すようなコールバックを受け取る形にしておくのが無難そうでした。

[widget/image.dart](https://api.flutter.dev/flutter/widgets/Image-class.html) の設計を参考にしつつ、ここでは [ImageErrorWidgetBuilder](https://api.flutter.dev/flutter/widgets/ImageErrorWidgetBuilder.html) の代わりにより汎用的な名前の [ErrorWidgetBuilder](https://api.flutter.dev/flutter/widgets/ErrorWidgetBuilder.html) を使用しています。
(が、ErrorWidgetBuilder の基本的な使い方とはちょっと違っているので、自作のエラーウィジェットビルダを定義するか、単に `Widget Function(FlutterErrorDetails details)` を型とする方がより安心かもしれません。)

```dart
// 自作のウィジェット
class MyWidget extends StatelessWidget {
  const MyWidget({
    super.key,
    // コンストラクタでエラー処理用のコールバックを受け取る
    required this.errorBuilder,
  });

  final ErrorWidgetBuilder errorBuilder;

  @override
  Widget build(BuildContext context) {
    try {
      throw Exception('build()中に例外発生');
    } catch(e, s) {
      // 実際には on を使って必要な場合のみ catch する
      return errorBuilder(
        FlutterErrorDetails(
          exception: e,
          stack: s,
          context: context,
        ),
      );
    }
  }
}

// 呼び出し側の例
class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: MyWidget(
        errorBuilder: (details) {
          // 必要があればログを吐いたりしつつ
          // 画面上に表示するウィジェットを返す
          return Text('エラーが発生しました');
        },
      ),
    );
  }
}
```

こうしておけば、エラー情報をウィジェットの呼び出し元に伝播させつつ、エラー処理も委ねることができます。

ここでは簡単のため required にしていますが、デフォルトの実装を持たせたり、指定がなければ今度こそスローしたりするような作りでもよいかもしれません。


# FutureBuilder でエラーが発生した場合の例

FutureBuilder や StreamBuilder ではエラーが発生した場合 AsyncSnapshot の error プロパティに値が入りますが、これも同様に処理することができます。

```dart
// 自作のウィジェットから抜粋
FutureBuilder<Foo>(
  future: _fetchFoo(),
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      return MyWidget(snapshot.data);
    } else if (snapshot.hasError) {
      return errorBuilder(
        FlutterErrorDetails(
          exception: snapshot.error!,
          stack: snapshot.stackTrace,
          context: context,
        ),
      );
    }
    return const CircularProgressIndicator();
  },
),
```

例えば `_fetchFoo()` がネットワークからデータを取ってくる処理であれば、タイムアウト時のエラー処理を呼び出し元に委ねたい場合などに使えるでしょう。


# 余談: build() 内から例外をスローすべきではない

ちなみにというか、`build()` 内で例外をスロー/リスローしてウィジェットの呼び出し元でキャッチしてもらうという素朴な方法はうまくいきません。

```dart:NG
// 自作のウィジェット
class MyWidget extends StatelessWidget {
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    throw Exception('ビルド中に例外発生');
  }
}

// 呼び出し側の例
class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    try {
      return Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: const MyWidget(),
      );
    } catch(e) {
      // ここでキャッチしてテキスト表示することを期待
      // 実際には通らない
      return Text(e.toString());
    }
  }
}
```

上記のような実装では catch で例外を捕捉することはできず、エラーログを吐きつつデフォルトのエラーウィジェットが表示されてしまいます。

> ======== Exception caught by widgets library =======================================================
> The following _Exception was thrown building MyWidget(dirty):
> Exception: ビルド中に例外発生

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141950/81a3a2e8-1f56-d06c-af8c-fb84b0420fa6.png" alt="" width="50%">

なお、[このエラーウィジェットはカスタマイズすることもできます](https://qiita.com/nabetaro_jp/items/1648cfc672cb702f8c23)が、これはあくまで想定外のエラーが発生した場合に表示されるウィジェットで、想定内のエラーをこの画面で表示すべきではないでしょう。(多分)


# おまけ: スナックバー表示

呼び出し側の例として、エラーアイコンを表示しつつエラー内容をスナックバー表示するコードを書いたのですが、最終的に使うのをやめたので備忘のためここに載せておきます。

```dart
// 呼び出し側の例
Widget build(BuildContext context) {
  Object? error;
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (error != null) {
      final snackBar = SnackBar(
        behavior: SnackBarBehavior.floating,
        content: Text('$error'),
      );
      ScaffoldMessenger.of(context).showSnackBar(snackBar);
    }
  });
  return MyWidget(
    errorBuilder: (details) {
      error = details.exception;
      return const Icon(Icons.error);
    },
  );
}
```