ImageとImageProvider
はじめに
モバイルアプリケーションには、画像の表示が欠かせません。
assetsとして同梱したり、ネットワークから取得したりと、さまざまな方法で画像を表示することになります。
しかし、この画像を表示する際にImageとImageProviderを理解して利用したことはあるでしょうか?
この記事では、ノリで利用しがちなImageとImageProviderについて理解を深めます。
…というテイで、画像読み込みライブラリを自作した際のコードリーディングの振り返りメモです。よろしくお願いします。
Image
ImageはStatefulWidgetを継承したWidgetです。このため、アプリケーションの中で画像を表示する際には、Imageを利用することになります。
Imageクラスには、下記5パターンのコンストラクタが用意されています。
Imageクラスを利用する場合には、このなかの特定の形式で画像データを取得するコンストラクタ、つまり1つ目を除いた4つのコンストラクタを利用することになります。
以下、公式ドキュメントからの引用です。
- Image.new, for obtaining an image from an ImageProvider.
- Image.asset, for obtaining an image from an AssetBundle using a key.
- Image.network, for obtaining an image from a URL.
- Image.file, for obtaining an image from a File.
- Image.memory, for obtaining an image from a Uint8List.
これら4つのコンストラクタを見比べてみると、.assetだけちょっとした違いがあるのですが、基本的にはfinal ImageProvider image;に対してResizedImage.resizeIfNeededをセットしています。
説明用に順番を整理の上、必要な箇所だけ引用すると、次のようになります。
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));
}
ここで現れるResizeImage、ImageProvider、NetworkImage、FileImage、ExactAssetImage、AssetImage、MemoryImageは(当然ですが)ImageProviderを継承しています。また、ここを確認するとImage.newがImageProviderを要求しているので、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がどのように接続されるのか、見ていきましょう。
何となく_handleImageFrameとfinal 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であれば、_imageStreamとgetListenerが紐づいていることがわかります。TickerModeがfalseである場合には、そもそもwidgetの更新アニメーションが走らない状態なので、Imageウィジェットが適切に動作していない状態になっている…ハズです。
以上で、final ImageProvider image;で指定したImageProviderが、_handleImageFrameの呼び出しにつながることがわかりました。
当初の予想通りですね。
続いて、ImageProviderを読んでいきたいところなのですが、その前にImageCacheを確認します。
ImageProviderのコードを読んでいくと明らかなのですが、ImageProvider内で読み込まれる画像は、ImageCacheにキャッシュされます。このキャッシュ処理が複雑なので、あらかじめImageCacheを確認しておき、ImageProviderのロジックをさっくり読んでしまおう、という試みです。
ImageCache
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メソッドを利用します。メソッドを確認していきましょう。
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.
実際にコードを見てみると、次のような記述になっています。なにこれ、って感じですね。
読み解ける方はそのまま読んでもらった方が良い[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]
ImageCacheSatusの生成方法を見ましたね?
このようにbool値を判定していることを踏まえて、ImageCacheStatusのドキュメントを確認します。
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はイマイチ掴みかねる要素です。
pendingとkeepAliveで保持するべきキャッシュは十分なように思えます。というかコメントにもあるように、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の責務です。
ImageProviderの継承クラスには、次の3つのメソッドが登場します。
-
obtainKey
- Converts an ImageProvider's settings plus an ImageConfiguration to a key that describes the precise image to load.
-
loadBuffer
- Converts a key into an ImageStreamCompleter, and begins fetching the image.
- This method is deprecated. Implement
loadImageinstead.
-
loadImage
- Converts a key into an ImageStreamCompleter, and begins fetching the image.
obtainKeyが特殊な実装になっているのは、ResizeImageとAssetImage、そしてExactAssetImageです。
AssetImageとExactAssetImageの違いは、Flutterのassetsで2.0xや3.0xのような複数の解像度が異なる画像を梱包した時に、それらを自動的に切り替える(AssetImage)か指定した解像度を表示する(ExactAssetImage)かになります。このため、この2つは別物ではありますが、ほぼ同じと見做せます。
また、これらはBundleを利用して画像を取得する必要があります。このため、obtainKeyの処理にAssetBundleが関係することとなり、他のImageProviderとは異なる実装となります。
そしてFlutter 3.7.0より、loadBufferはdeprecatedとなり、loadImageに置き換えられています。
要するに、見なければいけないのはloadImageだけ、ということです。
Imageクラスの中に登場した、ImageProviderの実装を並べてみます。
NetworkImageFileImageExactAssetImageAssetImageMemoryImageResizeImageScrollAwareImageProvider
このうち、ScrollAwareImageProviderはImageの中で登場しましたが、明らかに他のImageProviderとは異なる役割を担っています。まず、ScrollAawareImageProviderをさっと確認しつつ、次のImageProviderを詳細にみていきます。
ScrollAwareImageProvider
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です。
MemoryImageはUint8List、つまり画像のバイト列を受け取り表示します。
結局のところ、画像のデータをどこから(InternetやFileなど)から取得しても、最終的にはバイト列に変換する必要があるわけです。つまり、MemoryImageは開発者が自前でバイト列に変換したケースのImageProviderと言えます。
Decodes the given Uint8List buffer as an image, associating it with the given scale.
クラスは次の箇所にあります。前述の通り、確認が必要なのはloadImageだけです。
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になっているのは、他のクラスと実装を合わせるためです。
内容はすごい簡単ですね、Uint8Listをui.ImmutableBuffer.fromUint8Listで変換して、decodeメソッドに渡すだけです。ImageProviderはabstractクラスなので、loadImageを呼び出した後の処理が実装されています。
処理をImageクラスの実装から見ていくと次のようになります。
-
_ImageState#resolveImage-
ImageStreamを取得処理の中で、provider.resolveを呼び出す
-
-
ImageProvider#resolve-
createStreamメソッドによりImageStreamを新規に生成 - 生成した
ImageStreamをImageProvider#resolveStreamForKeyに渡す
-
-
ImageProvider.resolveStreamForKey- 2の処理で渡ってきた
stream.completerがnullかどうかをチェック- nullの場合
-
PaintingBinding.instance.imageCache.putIfAbsentのloaderを生成し、stream.completerにセット -
loaderの生成処理として、ImageProvider#loadImageを呼び出す
-
- nullでない場合
-
PaintingBinding.instance.imageCache.putIfAbsentのloaderにstream.completerをセット - ただし、このケースには
This is an unusual edge caseとコメントがある
-
- nullの場合
- 2の処理で渡ってきた
-
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:uiのinstantiateImageCodecWithSizeを呼び出しています。
コードとドキュメントを読むと、dart:uiのImageを作り出す処理である、ということがわかります。なお、flutterのengineに行き着いてしまったので、ここで一旦終了です。
話が横道に逸れてしまったのですが、MemoryImageの実装が確認できました。ようやく、ImageProviderとはなんなのかが掴めたのではないでしょうか?
NetworkImage
NetworkImageは、MemoryImageにネットワークからデータを取得する処理が追加された実装です。
ただ、MemoryImageと異なる点が2つあります。1つは「データの読み込みに時間がかかる」こと、もう1つは「webとそれ以外で通信に関する処理が異なる」ことです。
データの読み込み経過の通知
MemoryImageは、Uint8Listを受け取っているので、データの読み込みに時間がかかることはありません。
一方で、NetworkImageはネットワークからデータを取得するため、読み込みに時間がかかることがあります。
話は戻りますが、ImageクラスにはloadingBuilderというプロパティがありました。
これは、画像の読み込み中に表示するWidgetを指定するためのプロパティです。
このbuilderでは、次の3つの引数を受け取ることができます。
Function(BuildContext context, Widget child, ImageChunkEvent? loadingProgress)
_ImageState#bulidメソッドを確認するとわかるのですが、childにはRawImageかSemanticsが渡されています。
このため、childはnon-nullであることが保証されています。
画像の読み込み状態を確認するためには、loadingProgressを利用します。
ImageChunkEventはint cumulativeBytesLoadedとint? expectedTotalBytesの2つのプロパティを持ちます。
お察しの通り、cumulativeBytesLoadedは読み込み済みのバイト数、expectedTotalBytesは読み込むべきバイト数です。
NetworkImageでは、ネットワークリクエストを適切に処理し、ImageChunkEventを生成する必要があります。
ネットワークリクエストの処理
HTTPのGETリクエストを行う場合、大抵はhttpやdioを利用します。
が、NetworkImageはFlutterの基本的なクラスであり、これらのクラスを利用していません。
httpパッケージの中身を除いたことがある方はご存知だと思うのですが、httpパッケージはdart:ioとdart:htmlを利用しています。ioが利用できるmobileやdesktopと、利用できないwebで実装が分かれています。
同様の処理が、NetworkImageでも行われています。次の2ファイルです。
ここでは、主に利用されるであろう、ioの実装を見ていきます。
めっちゃ大変そうですね。今回は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クラスまで処理が戻ってきます。
このため、このchunkEventsがImageクラスの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:ioのHttpClientResponseをUint8Listに変換する処理です。通信処理そのものではなく、通信処理で得られた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句でchunkEventsをcloseしています。後片付けは大事ですね。
ResizeImage
さて、最後のImageProviderはResizeImageです。
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の各コンストラクタには、cacheWidthとcacheHeightがあります。
これらを指定しない場合、つまりnullの場合には、リサイズの必要がないので、providerをそのまま返却しています。
obtainKey
ResizeImageの実装を見ていきましょう。
まずは、obtainKeyです。
ResizeImageの特定には、引数にとるImageProviderのkeyとリサイズの設定を合成する必要があります。
リサイズの指定だけではどの画像をリサイズするのかが分からず、引数にとったImageProviderのkeyだけではどのようにリサイズするのかが分からないためです。
そして、obtainKeyが非同期処理になるケースも考慮する必要があります。ちょろっと触れた、AssetImageがAssetBundleを利用するため、非同期処理を行う必要があるためです。
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の実装は簡単に読めると思います。
final ImageStreamCompleter completer = imageProvider.loadImage(key._providerCacheKey, decodeResize);が最も重要な箇所です。
少しだけ確認します。
key._providerCacheKeyは、obtainKeyで生成したResizeImageKeyが渡されます。
先ほど確認した通り、ResizeImageKeyはImageProviderのkeyとリサイズの設定を合成したものです。このため、元データのkeyとは一致しません。このためImageCache上で、keyが衝突することはありません。
decodeResizeについては、これまでに確認してきたImageProviderの実装クラスでdecode(await ui.ImmutableBuffer.fromUint8List(bytes))を呼び出している箇所に、差し込まれる形でリサイズの指定がなされます。
処理が長いので引用は避けますが、Imageクラスのコンストラクタを利用している場合、policy = ResizeImagePolicy.exactかつallowUpscaling = falseとなります。これは、Resizeの目的がメモリの使用量を抑えることにあることを考えると、妥当な設定です。
ResizedImageの使いどころ
ResizeImageは、Image.newのコンストラクタで指定することで、任意の設定を与えることができます。
もしも画像のリサイズを細かく制御したい場合には、設定してみてください。
おわりに
FlutterのImageとImageProviderの実装を追ってみました。
ここまで読まれた方は、flutter_genで生成されるAssetGenImageや、cached_network_imageのCachedNetworkImageProviderの実装も読めるハズです。
と言うのもコードリーディングをしたのは、画像読み込みライブラリを作ってみよう、と思ったのがきっかけでした。実際、この辺まで読んだら動くものは作れています。
コードはこちら。
作り上げた後に見てみると、自分が「placeholderはパーセント表示しないだろう…」とカットした箇所を、cached_network_imageではプラットフォームを考慮してしっかりと作り込んでいたりします。
より正しく実装しようとすると、ああいった構成になるんだなぁ…と学ぶことができました。
以上。メモ書きでした。お付き合いいただきありがとうございます。
Discussion