【Flutter】ネットワークの画像をDioで取得して表示するWidget

# `Image.network`の問題

URLを指定して画像を表示するWidgetとしては`Image.network`が使われます。通常はこのWidgetで事足りますが、HTTP通信に`Dio`を使用したい、という用途には向きません。

https://pub.dev/packages/dio

`Image.network`の実装では`dart:http`の`HttpClient`で通信が実装されています。しかし`http`ライブラリはやや不便な点があり、特にinterceptorを利用した柔軟な処理は`dio`に軍配が上がると思います。

## デバッグモードでの差し替えの検討
`Image.network`の実装が使用する`HttpClient`インスタンスをデバックモードでのみ差し替える機会が用意されています。次のようなグローバルな変数を書き換えればよさそうです。

```dart:debug.dart
/// Provider from which [NetworkImage] will get its [HttpClient] in debug builds.
///
/// If this value is unset, [NetworkImage] will use its own internally-managed
/// [HttpClient].
///
/// This setting can be overridden for testing to ensure that each test receives
/// a mock client that hasn't been affected by other tests.
///
/// This value is ignored in non-debug builds.
HttpClientProvider? debugNetworkImageHttpClientProvider;
```

ただ、`Dio`クラスを使うには別途アダプターが必要・デバッグモード限定、と制約が多いのでやめました。

# Widgetを自作する
作った方が早い。

指定されたURLの画像を`Dio`で取得して`Image`に表示します。読み込み中は代わりのWidgetを表示することにしましょう。画像のデータ`Unit8Lit`を状態に持つWidgetですが、今回は`flutter_hooks`を利用します。

```diff:pubspec.yaml
 dependencies:
+  dio: ^4.0.6
+  flutter_hooks: ^0.18.3
```

```dart:my_network_image.dart
class MyNetworkImage extends HookWidget {
  const MyNetworkImage({
    Key? key,
    required this.dio,
    required this.srcPath,
    required this.placeHolder,
    this.fit,
  }) : super(key: key);

  final Dio dio;
  final String srcPath;
  final Widget placeHolder;
  final BoxFit? fit;

  @override
  Widget build(BuildContext context) {
    // 画像データ
    final imageData = useState<Uint8List?>(null);

    useEffect(
      // 初回のbuild時のみ実行
      () {
        // 画像データの取得
        final cancelToken = CancelToken();
        final call = dio.get<List<int>>(
          srcPath,
          cancelToken: cancelToken,
          options: Options(
            responseType: ResponseType.bytes,
            followRedirects: false,
          ),
        );
        call.then((response) {
          final data = response.data;
          if (data == null) throw Exception("empty data");
          final bytes = Uint8List.fromList(data);
          imageData.value = bytes;
        }).onError((e, _) {
          // エラーハンドリング（キャンセル含む）
          if (e is DioError && e.type == DioErrorType.cancel) {
            debugPrint("loading image data cancelled: $srcPath");
          } else {
            debugPrint("fail to load image data: $srcPath\n  caused by $e");
          }
        });
        return () {
          // 画像データの取得が終わる前にWidgetがdisposeされたらキャンセルする
          cancelToken.cancel("widget disposed");
        };
      },
      [],
    );

    final bytes = imageData.value;
    if (bytes == null) {
      return placeHolder;
    } else {
      return Image.memory(
        bytes,
        fit: fit,
        frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
          if (wasSynchronouslyLoaded || frame != null) {
            // すでに取得済みの場合
            return child;
          } else {
            // render前
            return placeHolder;
          }
        },
      );
    }
  }
}
```

1. `useState`で表示したい画像データ有無の状態を管理する
2. `useEffect`で初回Build時のみ画像データのダウンロードを開始する
3. 画像データをダウンロードする間は`placeHolder`を表示する
4. 画像データを取得したら`Image.memory`で表示

実装の補足
- 今回はhooksを使って実装しましたが、`StatefulWidget`で`initState(), setState()`を利用しても実装できます  
- `Image.memory`の引数`frameBuilder`を指定しないと、`placeHolder`に代わり取得した画像データを表示する一瞬の間だけなにも表示されないフレームが発生する場合があります。そこで、画像データの読み込みが完了するまでのフレームでは引き続き`placeHolder`を表示させています。