🤳

ImageとImageProvider

2024/01/05に公開

はじめに

モバイルアプリケーションには、画像の表示が欠かせません。
assetsとして同梱したり、ネットワークから取得したりと、さまざまな方法で画像を表示することになります。

しかし、この画像を表示する際にImageImageProvider理解して利用したことはあるでしょうか?
この記事では、ノリで利用しがちなImageImageProviderについて理解を深めます。

……というテイで、画像読み込みライブラリを自作した際のコードリーディングの振り返りメモです。よろしくお願いします。

Image

https://api.flutter.dev/flutter/widgets/Image-class.html

ImageStatefulWidgetを継承したWidgetです。このため、アプリケーションの中で画像を表示する際には、Imageを利用することになります。

Imageクラスには、下記5パターンのコンストラクタが用意されています。
Imageクラスを利用する場合には、このなかの特定の形式で画像データを取得するコンストラクタ、つまり1つ目を除いた4つのコンストラクタを利用することになります。

以下、公式ドキュメントからの引用です。

これら4つのコンストラクタを見比べてみると、.assetだけちょっとした違いがあるのですが、基本的にはfinal ImageProvider image;に対してResizedImage.resizeIfNeededをセットしています。

https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/widgets/image.dart

説明用に順番を整理の上、必要な箇所だけ引用すると、次のようになります。

class Image extends StatefulWidget {

  /// The image to display.
  final ImageProvider image;

  Image.network(
    String src, {
    super.key,
    double scale = 1.0,
    int? cacheWidth,
    int? cacheHeight,
  }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, NetworkImage(src, scale: scale, headers: headers));

  Image.file(
    File file, {
    super.key,
    double scale = 1.0,
    int? cacheWidth,
    int? cacheHeight,
  }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, FileImage(file, scale: scale));

  Image.asset(
    String name, {
    super.key,
    AssetBundle? bundle,
    String? package,
    double? scale,
    int? cacheWidth,
    int? cacheHeight,
  }) : image = ResizeImage.resizeIfNeeded(
        cacheWidth,
        cacheHeight,
        scale != null
          ? ExactAssetImage(name, bundle: bundle, scale: scale, package: package)
          : AssetImage(name, bundle: bundle, package: package),
      );

  Image.memory(
    Uint8List bytes, {
    super.key,
    double scale = 1.0,
    int? cacheWidth,
    int? cacheHeight,
  }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, MemoryImage(bytes, scale: scale));
}

ここで現れるResizeImageImageProviderNetworkImageFileImageExactAssetImageAssetImageMemoryImageは(当然ですが)ImageProviderを継承しています。また、ここを確認するとImage.newImageProviderを要求しているので、ImageProviderを自作した場合にはImage.newを利用すればいいことがわかりますね。

Imageクラスは非常によくできており、リソースの読み込み周りをImageProviderに分割することで、Widgetとしての処理を共通化しています。buildメソッド周辺を見てみましょう。

class _ImageState extends State<Image> with WidgetsBindingObserver {
  ImageInfo? _imageInfo;

  void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _replaceImage(info: imageInfo);
      _loadingProgress = null;
      _lastException = null;
      _frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
      _wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
    });
  }

  void _replaceImage({required ImageInfo? info}) {
    final ImageInfo? oldImageInfo = _imageInfo;
    SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
    _imageInfo = info;
  }

  
  Widget build(BuildContext context) {
    if (_lastException != null) {
      if (widget.errorBuilder != null) {
        return widget.errorBuilder!(context, _lastException!, _lastStack);
      }
      if (kDebugMode) {
        return _debugBuildErrorWidget(context, _lastException!);
      }
    }

    Widget result = RawImage(
      // Do not clone the image, because RawImage is a stateless wrapper.
      // The image will be disposed by this state object when it is not needed
      // anymore, such as when it is unmounted or when the image stream pushes
      // a new image.
      image: _imageInfo?.image,
      debugImageLabel: _imageInfo?.debugLabel,
      width: widget.width,
      height: widget.height,
      scale: _imageInfo?.scale ?? 1.0,
      color: widget.color,
      opacity: widget.opacity,
      colorBlendMode: widget.colorBlendMode,
      fit: widget.fit,
      alignment: widget.alignment,
      repeat: widget.repeat,
      centerSlice: widget.centerSlice,
      matchTextDirection: widget.matchTextDirection,
      invertColors: _invertColors,
      isAntiAlias: widget.isAntiAlias,
      filterQuality: widget.filterQuality,
    );

    if (!widget.excludeFromSemantics) {
      result = Semantics(
        container: widget.semanticLabel != null,
        image: true,
        label: widget.semanticLabel ?? '',
        child: result,
      );
    }

    if (widget.frameBuilder != null) {
      result = widget.frameBuilder!(context, result, _frameNumber, _wasSynchronouslyLoaded);
    }

    if (widget.loadingBuilder != null) {
      result = widget.loadingBuilder!(context, result, _loadingProgress);
    }

    return result;
  }
}

Imageクラスの引数として、もしくは画像読み込みライブラリで指定したことのある、いくつかのbuilderが登場します。
現時点では、何らかのStreamをハンドリングしていることとImageInfoをハンドリングしていることを把握しておけば、あとはbuildメソッド内でよしなに処理が行われることが把握できるのではないでしょうか。


では、ImageProviderがどのように接続されるのか、見ていきましょう。
何となく_handleImageFramefinal ImageProvider imageが組み合わされていることが予想できます。なので、その予想を確かめていきます。

まず、_handleImageFrameが呼び出される_getListenerメソッドを見てみましょう。recreateListenerには、didUpdateWidgetでWidgetの中身を更新する必要がある時に、trueが渡されます。

処理が多いように見えますが、builderの指定がないケースでは、それぞれnullの判定をしているだけです。

class _ImageState extends State<Image> with WidgetsBindingObserver {
  ImageStreamListener? _imageStreamListener;

  ImageStreamListener _getListener({bool recreateListener = false}) {
    if (_imageStreamListener == null || recreateListener) {
      _lastException = null;
      _lastStack = null;
      _imageStreamListener = ImageStreamListener(
        _handleImageFrame,
        onChunk: widget.loadingBuilder == null ? null : _handleImageChunk,
        onError: widget.errorBuilder != null || kDebugMode
            ? (Object error, StackTrace? stackTrace) {
                setState(() {
                  _lastException = error;
                  _lastStack = stackTrace;
                });
                assert(() {
                  if (widget.errorBuilder == null) {
                    // ignore: only_throw_errors, since we're just proxying the error.
                    throw error; // Ensures the error message is printed to the console.
                  }
                  return true;
                }());
              }
            : null,
      );
    }
    return _imageStreamListener!;
  }
}

/// Interface for receiving notifications about the loading of an image.

class ImageStreamListener {
  const ImageStreamListener(
    this.onImage, {
    this.onChunk,
    this.onError,
  });

  final ImageListener onImage;
  final ImageChunkListener? onChunk;
  final ImageErrorListener? onError;
}

typedef ImageListener = void Function(ImageInfo image, bool synchronousCall);
typedef ImageChunkListener = void Function(ImageChunkEvent event);
typedef ImageErrorListener = void Function(Object exception, StackTrace? stackTrace);

続いて、_getListenerメソッドのことを頭の片隅に置きつつ、ImageStreamを処理している箇所を追いかけます。なお、可読性のために省略している箇所があります。

class _ImageState extends State<Image> with WidgetsBindingObserver {
  ImageStream? _imageStream;
  bool _isListeningToStream = false;

  
  void didChangeDependencies() {
    _resolveImage();

    if (TickerMode.of(context)) {
      _listenToStream();
    } else {
      _stopListeningToStream(keepStreamAlive: true);
    }

    super.didChangeDependencies();
  }

  void _resolveImage() {
    final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
      context: _scrollAwareContext,
      imageProvider: widget.image,
    );
    final ImageStream newStream =
      provider.resolve(createLocalImageConfiguration(
        context,
        size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
      ));
    _updateSourceStream(newStream);
  }

   void _updateSourceStream(ImageStream newStream) {
    if (_imageStream?.key == newStream.key) {
      return;
    }

    if (_isListeningToStream) {
      _imageStream!.removeListener(_getListener());
    }

    if (!widget.gaplessPlayback) {
      setState(() { _replaceImage(info: null); });
    }

    setState(() {
      _frameNumber = null;
      _wasSynchronouslyLoaded = false;
    });

    _imageStream = newStream;
    if (_isListeningToStream) {
      _imageStream!.addListener(_getListener());
    }
  }

  void _listenToStream() {
    if (_isListeningToStream) {
      return;
    }

    _imageStream!.addListener(_getListener());
    _completerHandle?.dispose();
    _completerHandle = null;

    _isListeningToStream = true;
  }
}

ScrollAwareImageProviderが登場しましたが、一旦目を瞑りましょう。これは後ほどImageProviderの箇所で確認します。
_imageStreamに目を向けてみると、TickerModeがtrueであれば、_imageStreamgetListenerが紐づいていることがわかります。TickerModeがfalseである場合には、そもそもwidgetの更新アニメーションが走らない状態なので、Imageウィジェットが適切に動作していない状態になっている……ハズです。

以上で、final ImageProvider image;で指定したImageProviderが、_handleImageFrameの呼び出しにつながることがわかりました。
当初の予想通りですね。


続いて、ImageProviderを読んでいきたいところなのですが、その前にImageCacheを確認します。
ImageProviderのコードを読んでいくと明らかなのですが、ImageProvider内で読み込まれる画像は、ImageCacheにキャッシュされます。このキャッシュ処理が複雑なので、あらかじめImageCacheを確認しておき、ImageProviderのロジックをさっくり読んでしまおう、という試みです。

ImageCache

https://api.flutter.dev/flutter/painting/ImageCache-class.html

Class for caching images.

Implements a least-recently-used cache of up to 1000 images, and up to 100 MB. The maximum size can be adjusted using maximumSize and maximumSizeBytes.

ImageCacheは、メモリで画像をキャッシュするためのクラスです。Lruキャッシュを利用しており、メモリを効率的に利用しています。
Androidエンジニアの方であれば、PicassoやGlideのメモリキャッシュと同じ方式と言えば、一発でわかるハズです。[1]

ImageCacheのインスタンスは、PaintingBinding.instance.imageCacheで取得できます。
PaintingBindingのsingletonインスタンス上でimageCacheを保持しています。ImageCacheは1つで十分と言うか、1つだからこそ意味があるので、この実装は妥当ですね。


ImageCacheの実装を読んでいると、最もびっくりさせられるのはImageProviderをキーImageStreamCompleterを管理している点です。

Lruキャッシュで新規にキャッシュを追加する場合には、putIfAbsentメソッドを利用します。メソッドを確認していきましょう。

https://api.flutter.dev/flutter/painting/ImageCache/putIfAbsent.html

Returns the previously cached ImageStream for the given key, if available; if not, calls the given callback to obtain it first. In either case, the key is moved to the 'most recently used' position.

In the event that the loader throws an exception, it will be caught only if onError is also provided. When an exception is caught resolving an image, no completers are cached and null is returned instead of a new completer.

実際にコードを見てみると、次のような記述になっています。なにこれ、って感じですね。

https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/painting/image_cache.dart#L322-L455

読み解ける方はそのまま読んでもらった方が良い[2]のですが、一応解説を試みます。

ImageCacacheでは次の3つのMapを管理しています。
このMapは順序を持っています。このためfirstで古いものから、lastで新しいものからアクセスすることができます。

  • final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
  • final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
  • final Map<Object, _LiveImage> _liveImages = <Object, _LiveImage>{};

この3つのMapは、それぞれ異なる目的を持ちます。
コードを読んで把握しようとすると大変辛いので、ImageCache#statusForKeyで取得できるImageCacheStatusクラスの説明を確認します。[3]

https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/painting/image_cache.dart#L458-L464

ImageCacheSatusの生成方法を見ましたね?
このようにbool値を判定していることを踏まえて、ImageCacheStatusのドキュメントを確認します。

https://api.flutter.dev/flutter/painting/ImageCacheStatus-class.html

A pending image is one that has not completed yet. It may also be tracked as live because something is listening to it.

A keepAlive image is being held in the cache, which uses Least Recently Used semantics to determine when to evict an image. These images are subject to eviction based on ImageCache.maximumSizeBytes and ImageCache.maximumSize. It may be live, but not pending.

A live image is being held until its ImageStreamCompleter has no more listeners. It may also be pending or keepAlive.

pendingで言及されているのは、putIfAbsentの第2引数で指定したloaderであり、その実態はImageProvider#loadImageです。
またpendingつまり_pendingImagesで完了した処理は、keepAliveつまり_cacheに移し替えられます。この2つが保持されていることと、2つの関係性は、直感的に理解しやすいと思います。

一方で、liveはイマイチ掴みかねる要素です。
pendingkeepAliveで保持するべきキャッシュは十分なように思えます。というかコメントにもあるように、liveはこれらのキャッシュと二重に保持されます。なぜなのでしょうか?

_liveImagesに保持されliveの判定になるのは、putIfAbsentで返却されたImageStreamCompleterが存在している状態です。
この存在している限りは、ImageクラスのStateとして保持されていることを意味します。コードとしては_ImageState_resolveImageにて、ImageStreamが生成されStateとして保持されている状態です。

これは、複数の画像をColumnで並べたようなケースを想定すると、イメージしやすいのではないでしょうか。
Columnは、子要素のWidgetを全て生成します。すると、子要素になっているすべてのImageにおいて、ImageStreamCompleterは保持されている状態になります。これがListView.builderだと、keepAliveの指定にもよるのですが、大抵は画面に表示されている子要素のみが保持されている状態です。

このように今表示されている画像が、liveつまり_liveImagesにキャッシュされます。

_cache_liveImagesには、キャッシュのサイズがチェックされるかどうか、という違いがあります。
_cacheは設定されたサイズを超えると、古いものから削除される処理があります。一方で、_liveImagesにはありません。30MBぐらいのでかい画像が複数表示されていることを見ると、納得のいく動きになっているのがわかると思います。


以上で、ImageCacheの実装を確認できました。
ここから、本題であるImageProviderを確認します。

ImageProvider

長々とImageを読み進めてみると、ImageクラスがRowImageを表示するためのWidgetであることがわかります。
読み込み処理やキャッシュ処理などは、全てImageProviderの責務です。

https://api.flutter.dev/flutter/painting/ImageProvider-class.html


ImageProviderの継承クラスには、次の3つのメソッドが登場します。

obtainKeyが特殊な実装になっているのは、ResizeImageAssetImage、そしてExactAssetImageです。
AssetImageExactAssetImageの違いは、Flutterのassetsで2.0x3.0xのような複数の解像度が異なる画像を梱包した時に、それらを自動的に切り替える(AssetImage)か指定した解像度を表示する(ExactAssetImage)かになります。このため、この2つは別物ではありますが、ほぼ同じと見做せます。

また、これらはBundleを利用して画像を取得する必要があります。このため、obtainKeyの処理にAssetBundleが関係することとなり、他のImageProviderとは異なる実装となります。

そしてFlutter 3.7.0より、loadBufferはdeprecatedとなり、loadImageに置き換えられています。
要するに、見なければいけないのはloadImageだけ、ということです。


Imageクラスの中に登場した、ImageProviderの実装を並べてみます。

  • NetworkImage
  • FileImage
  • ExactAssetImage
  • AssetImage
  • MemoryImage
  • ResizeImage
  • ScrollAwareImageProvider

このうち、ScrollAwareImageProviderImageの中で登場しましたが、明らかに他のImageProviderとは異なる役割を担っています。まず、ScrollAawareImageProviderをさっと確認しつつ、次のImageProviderを詳細にみていきます。

ScrollAwareImageProvider

https://api.flutter.dev/flutter/widgets/ScrollAwareImageProvider-class.html

ScrollAwareImageProviderは、ドキュメントの説明を読めば、その役割がわかります。

An ImageProvider that makes use of Scrollable.recommendDeferredLoadingForContext to avoid loading images when rapidly scrolling.

This provider assumes that its wrapped imageProvider correctly uses the ImageCache, and does not attempt to re-acquire or decode images in the cache.

前半は『ScrollAwareImageProviderは、Scrollable.recommendDeferredLoadingForContextを利用して、高速スクロール時に画像を読み込まないようにします。』ということですね。高速スクロール時に画像を読み込まないことで、スクロールのパフォーマンスを保っているのかな、と思います。

後半の内容は、先ほど確認したImageCacheの仕組みを前提としている、と言うことです。
ImageCacheでは、読み込み前から読み込み後、現在参照されている画像読み込み処理をキャッシュしています。このため、ImageProviderの継承クラスが正確な実装となり、適切にimageCacheを利用していれば、ラッパーであるScrollAwareImageProviderが画像の再取得やデコードを行う必要がありません。

MemoryImage

ImageProviderの最もシンプルな実装は、MemoryImageです。
MemoryImageUint8List、つまり画像のバイト列を受け取り表示します。

結局のところ、画像のデータをどこから(InternetやFileなど)から取得しても、最終的にはバイト列に変換する必要があるわけです。つまり、MemoryImageは開発者が自前でバイト列に変換したケースのImageProviderと言えます。

https://api.flutter.dev/flutter/painting/MemoryImage-class.html

Decodes the given Uint8List buffer as an image, associating it with the given scale.

クラスは次の箇所にあります。前述の通り、確認が必要なのはloadImageだけです。

https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/painting/image_provider.dart#L1522

import 'dart:ui' as ui;


class MemoryImage extends ImageProvider<MemoryImage> {
  /// Creates an object that decodes a [Uint8List] buffer as an image.
  const MemoryImage(this.bytes, { this.scale = 1.0 });

  final Uint8List bytes;
  final double scale;  

  
  ImageStreamCompleter loadImage(MemoryImage key, ImageDecoderCallback decode) {
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, decode: decode),
      scale: key.scale,
      debugLabel: 'MemoryImage(${describeIdentity(key.bytes)})',
    );
  }

  Future<ui.Codec> _loadAsync(
    MemoryImage key, {
    required _SimpleDecoderCallback decode,
  }) async {
    assert(key == this);
    return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
  }
}

typedef ImageDecoderCallback = Future<ui.Codec> Function(
  ui.ImmutableBuffer buffer, {
  ui.TargetImageSizeCallback? getTargetSize,
});

_loadAsyncになっているのは、他のクラスと実装を合わせるためです。
内容はすごい簡単ですね、Uint8Listui.ImmutableBuffer.fromUint8Listで変換して、decodeメソッドに渡すだけです。ImageProviderはabstractクラスなので、loadImageを呼び出した後の処理が実装されています。


処理をImageクラスの実装から見ていくと次のようになります。

  1. _ImageState#resolveImage
    • ImageStreamを取得処理の中で、provider.resolveを呼び出す
  2. ImageProvider#resolve
    • createStreamメソッドによりImageStreamを新規に生成
    • 生成したImageStreamImageProvider#resolveStreamForKeyに渡す
  3. ImageProvider.resolveStreamForKey
    • 2の処理で渡ってきたstream.completerがnullかどうかをチェック
      • nullの場合
        • PaintingBinding.instance.imageCache.putIfAbsentloaderを生成し、stream.completerにセット
        • loaderの生成処理として、ImageProvider#loadImageを呼び出す
      • nullでない場合
        • PaintingBinding.instance.imageCache.putIfAbsentloaderstream.completerをセット
        • ただし、このケースにはThis is an unusual edge caseとコメントがある
  4. ImageProvider.loadImage
    • ImageProviderはabstractクラスなので、loadImageは継承したクラスのものを呼び出す
    • MemoryImageの場合にはMemoryImage#loadImageが呼ばれる

具体的に3の処理をみると、次の実装です。筆者がコメントを一部書き換えています。


void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError) {
  // エッジケースのパターン
  if (stream.completer != null) {
    final ImageStreamCompleter? completer = PaintingBinding.instance.imageCache.putIfAbsent(
      key,
      () => stream.completer!,
      onError: handleError,
    );
    return;
  }
  final ImageStreamCompleter? completer = PaintingBinding.instance.imageCache.putIfAbsent(
    key,
    () {
      ImageStreamCompleter result = loadImage(key, PaintingBinding.instance.instantiateImageCodecWithSize);
      // 元コードには、ここでloadBufferを呼び直す処理
      return result;
    },
    onError: handleError,
  );
  if (completer != null) {
    stream.setCompleter(completer);
  }
}

PaintingBinding.instance.instantiateImageCodecWithSizeは、dart:uiinstantiateImageCodecWithSizeを呼び出しています。
コードとドキュメントを読むと、dart:uiImageを作り出す処理である、ということがわかります。なお、flutterのengineに行き着いてしまったので、ここで一旦終了です。

https://api.flutter.dev/flutter/dart-ui/instantiateImageCodec.html

https://github.com/flutter/engine/blob/3.16.0/lib/ui/painting.dart#L2191

話が横道に逸れてしまったのですが、MemoryImageの実装が確認できました。ようやく、ImageProviderとはなんなのかが掴めたのではないでしょうか?

NetworkImage

https://api.flutter.dev/flutter/painting/NetworkImage-class.html

NetworkImageは、MemoryImageにネットワークからデータを取得する処理が追加された実装です。
ただ、MemoryImageと異なる点が2つあります。1つは「データの読み込みに時間がかかる」こと、もう1つは「webとそれ以外で通信に関する処理が異なる」ことです。

データの読み込み経過の通知

MemoryImageは、Uint8Listを受け取っているので、データの読み込みに時間がかかることはありません。
一方で、NetworkImageはネットワークからデータを取得するため、読み込みに時間がかかることがあります。

話は戻りますが、ImageクラスにはloadingBuilderというプロパティがありました。
これは、画像の読み込み中に表示するWidgetを指定するためのプロパティです。

https://api.flutter.dev/flutter/widgets/Image/loadingBuilder.html

このbuilderでは、次の3つの引数を受け取ることができます。

Function(BuildContext context, Widget child, ImageChunkEvent? loadingProgress)

_ImageState#bulidメソッドを確認するとわかるのですが、childにはRawImageSemanticsが渡されています。
このため、childはnon-nullであることが保証されています。
画像の読み込み状態を確認するためには、loadingProgressを利用します。

https://api.flutter.dev/flutter/painting/ImageChunkEvent-class.html

ImageChunkEventint cumulativeBytesLoadedint? expectedTotalBytesの2つのプロパティを持ちます。
お察しの通り、cumulativeBytesLoadedは読み込み済みのバイト数、expectedTotalBytesは読み込むべきバイト数です。

NetworkImageでは、ネットワークリクエストを適切に処理し、ImageChunkEventを生成する必要があります。

ネットワークリクエストの処理

HTTPのGETリクエストを行う場合、大抵はhttpdioを利用します。
が、NetworkImageはFlutterの基本的なクラスであり、これらのクラスを利用していません。

httpパッケージの中身を除いたことがある方はご存知だと思うのですが、httpパッケージはdart:iodart:htmlを利用しています。ioが利用できるmobileやdesktopと、利用できないwebで実装が分かれています。
同様の処理が、NetworkImageでも行われています。次の2ファイルです。

https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/painting/_network_image_io.dart

https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/painting/_network_image_web.dart

ここでは、主に利用されるであろう、ioの実装を見ていきます。


https://github.com/flutter/flutter/blob/3.16.0/packages/flutter/lib/src/painting/_network_image_io.dart#L58-L143

めっちゃ大変そうですね。今回はchunkを追いたいので、final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();を中心に見ていきます。
_loadAsyncメソッドの引数に、chunkEventsが渡しているのがMemoryImageとの差分その1。そして、MultiFrameImageStreamCompleterの引数に、chunkEvents.streamが渡されているのが差分その2です。


class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage> implements image_provider.NetworkImage {
  const NetworkImage(this.url, { this.scale = 1.0, this.headers });

  
  final String url;

  
  final double scale;

  
  final Map<String, String>? headers;

  
  ImageStreamCompleter loadImage(image_provider.NetworkImage key, image_provider.ImageDecoderCallback decode) {
    // Ownership of this controller is handed off to [_loadAsync]; it is that
    // method's responsibility to close the controller's stream when the image
    // has been loaded or an error is thrown.
    final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();

    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key as NetworkImage, chunkEvents, decode: decode),
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      debugLabel: key.url,
      informationCollector: () => <DiagnosticsNode>[
        DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
        DiagnosticsProperty<image_provider.NetworkImage>('Image key', key),
      ],
    );
  }
}

MultiFrameImageStreamCompleterは、先ほど確認した通りImageクラスまで処理が戻ってきます。
このため、このchunkEventsImageクラスのloadingBuilderまで届き、通信中かどうかを確認できるようになります。

続いて、_loadAsyncメソッドを見て見ましょう。通信処理のアレコレを見なかったことにすると、次の箇所がchunkを処理していることがわかります。

final Uint8List bytes = await consolidateHttpClientResponseBytes(
  response,
  onBytesReceived: (int cumulative, int? total) {
    chunkEvents.add(ImageChunkEvent(
      cumulativeBytesLoaded: cumulative,
      expectedTotalBytes: total,
    ));
  },
);
if (bytes.lengthInBytes == 0) {
  throw Exception('NetworkImage is an empty file: $resolved');
}

return decode(await ui.ImmutableBuffer.fromUint8List(bytes));

decodeについては、MemoryImageと同じですね。なので、onBytesReceivedにてchunkEventsにaddする箇所が差分その3です。

consolidateHttpClientResponseBytesは、dart:ioHttpClientResponseUint8Listに変換する処理です。通信処理そのものではなく、通信処理で得られたbodyをUint8Listに変換する処理になります。

なお、通信処理が失敗したケースと完了後も考慮する必要があります。

} catch (e) {
  // Depending on where the exception was thrown, the image cache may not
  // have had a chance to track the key in the cache at all.
  // Schedule a microtask to give the cache a chance to add the key.
  scheduleMicrotask(() {
    PaintingBinding.instance.imageCache.evict(key);
  });
  rethrow;
} finally {
  chunkEvents.close();
}

通信が失敗したケースでは、ImageCacheからキャッシュの削除を行なっています。万が一キャッシュが残ってしまうと、次に同じURLで画像を取得しようとした時に、通信の失敗結果をキャッシュから取得することになるためです。
最後に、finaly句でchunkEventscloseしています。後片付けは大事ですね。

ResizeImage

さて、最後のImageProviderResizeImageです。

https://api.flutter.dev/flutter/painting/ResizeImage-class.html

ResizeImageそのものを見る前に、まずResizeImage.resizeIfNeededを見てみましょう。

class ResizeImage extends ImageProvider<ResizeImageKey> {
  static ImageProvider<Object> resizeIfNeeded(int? cacheWidth, int? cacheHeight, ImageProvider<Object> provider) {
    if (cacheWidth != null || cacheHeight != null) {
      return ResizeImage(provider, width: cacheWidth, height: cacheHeight);
    }
    return provider;
  }
}

Imageの各コンストラクタには、cacheWidthcacheHeightがあります。
これらを指定しない場合、つまりnullの場合には、リサイズの必要がないので、providerをそのまま返却しています。

obtainKey

ResizeImageの実装を見ていきましょう。
まずは、obtainKeyです。

ResizeImageの特定には、引数にとるImageProviderkeyとリサイズの設定を合成する必要があります。
リサイズの指定だけではどの画像をリサイズするのかが分からず、引数にとったImageProviderkeyだけではどのようにリサイズするのかが分からないためです。

そして、obtainKeyが非同期処理になるケースも考慮する必要があります。ちょろっと触れた、AssetImageAssetBundleを利用するため、非同期処理を行う必要があるためです。

class ResizeImage extends ImageProvider<ResizeImageKey> {
  
  Future<ResizeImageKey> obtainKey(ImageConfiguration configuration) {
    Completer<ResizeImageKey>? completer;
    // If the imageProvider.obtainKey future is synchronous, then we will be able to fill in result with
    // a value before completer is initialized below.
    SynchronousFuture<ResizeImageKey>? result;
    imageProvider.obtainKey(configuration).then((Object key) {
      if (completer == null) {
        // This future has completed synchronously (completer was never assigned),
        // so we can directly create the synchronous result to return.
        result = SynchronousFuture<ResizeImageKey>(ResizeImageKey._(key, policy, width, height, allowUpscaling));
      } else {
        // This future did not synchronously complete.
        completer.complete(ResizeImageKey._(key, policy, width, height, allowUpscaling));
      }
    });
    if (result != null) {
      return result!;
    }
    // If the code reaches here, it means the imageProvider.obtainKey was not
    // completed sync, so we initialize the completer for completion later.
    completer = Completer<ResizeImageKey>();
    return completer.future;
  }
}

ResizeImageKeyは単なるデータクラスなので、特に気にする必要はありません。[4]実はclass ResizeImage extends ImageProvider<ResizeImageKey>と定義されているので、外部に公開されている必要があるクラスもでもあったりします。

loadImage

次に、loadImageを見ていきましょう。
ここまでの実装を見てきた方であれば、loadImageの実装は簡単に読めると思います。

https://github.com/flutter/flutter/blob/78666c8dc5/packages/flutter/lib/src/painting/image_provider.dart#L1274-L1342

final ImageStreamCompleter completer = imageProvider.loadImage(key._providerCacheKey, decodeResize);が最も重要な箇所です。
少しだけ確認します。

key._providerCacheKeyは、obtainKeyで生成したResizeImageKeyが渡されます。
先ほど確認した通り、ResizeImageKeyImageProviderkeyとリサイズの設定を合成したものです。このため、元データのkeyとは一致しません。このためImageCache上で、keyが衝突することはありません。

decodeResizeについては、これまでに確認してきたImageProviderの実装クラスでdecode(await ui.ImmutableBuffer.fromUint8List(bytes))を呼び出している箇所に、差し込まれる形でリサイズの指定がなされます。
処理が長いので引用は避けますが、Imageクラスのコンストラクタを利用している場合、policy = ResizeImagePolicy.exactかつallowUpscaling = falseとなります。これは、Resizeの目的がメモリの使用量を抑えることにあることを考えると、妥当な設定です。

ResizedImageの使いどころ

ResizeImageは、Image.newのコンストラクタで指定することで、任意の設定を与えることができます。
もしも画像のリサイズを細かく制御したい場合には、設定してみてください。

おわりに

FlutterのImageImageProviderの実装を追ってみました。

ここまで読まれた方は、flutter_genで生成されるAssetGenImageや、cached_network_imageCachedNetworkImageProviderの実装も読めるハズです。

と言うのもコードリーディングをしたのは、画像読み込みライブラリを作ってみよう、と思ったのがきっかけでした。実際、この辺まで読んだら動くものは作れています。

https://pub.dev/packages/taro

コードはこちら。

https://github.com/koji-1009/taro

作り上げた後に見てみると、自分が「placeholderはパーセント表示しないだろう……」とカットした箇所を、cached_network_imageではプラットフォームを考慮してしっかりと作り込んでいたりします。
より正しく実装しようとすると、ああいった構成になるんだなぁ……と学ぶことができました。

以上。メモ書きでした。お付き合いいただきありがとうございます。

脚注
  1. Coilもコードを見る感じ、Lruキャッシュっぽいですね ↩︎

  2. 解説できない内容が多いので…… ↩︎

  3. 一度コードから説明しようとして、この節は全部書き直しています ↩︎

  4. コードを見る限り、record typeでも良さそうです。コンストラクタをprivateにするためにclassになっているのかな? ↩︎

GitHubで編集を提案

Discussion