banner
jzman

jzman

Coding、思考、自觉。
github

Flutterシリーズの画像読み込み詳細

PS:自律実践は本当に想像ほど簡単ではありません。

Flutter がサポートする画像タイプ:JPEG、PNG、GIF、WebP、BMP、WBMP。Flutter Image コンポーネントの必須パラメータはImageProviderで、ImageProviderは抽象クラスであり、具体的な画像の取得はサブクラスによって実装されます。本記事では、以下のいくつかの側面から Flutter における画像の読み込みを学びます。

  1. 画像の読み込み
  2. 画像のプリロード
  3. 画像のキャッシュ
  4. 画像キャッシュのクリア
  5. 画像の読み込み進捗のリスニング
  6. 画像読み込みのサンプル

画像の読み込み#

Flutter 自体は画像の読み込みを実装しており、ネットワーク、SD カード、アセット、メモリ内の画像を読み込むことができます。以下の方法で画像リソースに対応する Image を生成できます:

Image.network(String src,{...});
Image.file(File file,{...});
Image.asset(String name,{...});
Image.memory(Uint8List bytes,{...});

以下に、ネットワーク画像の読み込みを例に Flutter における画像の読み込みの流れを紹介します。Image.network()のソースコードを確認します:

Image.network(
// ...
}) : image = NetworkImage(src, scale: scale, headers: headers),
   assert(alignment != null),
   assert(repeat != null),
   assert(matchTextDirection != null),
   super(key: key);

Image.networkを使用して Image を生成する際にNetworkImageが作成されます。NetworkImageクラスはImageProviderのサブクラスであり、ImageProviderは抽象クラスで、画像リソースを解析するresolveメソッド、画像キャッシュを削除するevictメソッド、画像を読み込む抽象メソッドloadなどを提供しています。loadメソッドはサブクラスによって具体的に実装されます。ImageProviderのソースコード分析は以下の通りです:

/// ImageProviderは抽象クラスで、具体的な読み込みはサブクラスによって実装されます
abstract class ImageProvider<T> {
  const ImageProvider();

  /// 提供されたImageConfigurationオブジェクトを使用してImageStreamを生成します
  ImageStream resolve(ImageConfiguration configuration) {
    assert(configuration != null);
    final ImageStream stream = ImageStream();
    T obtainedKey;

    //...コード

    dangerZone.runGuarded(() {
      Future<T> key;
      try {
        // 画像リソースに対応するkeyを取得
        key = obtainKey(configuration);
      } catch (error, stackTrace) {
        handleError(error, stackTrace);
        return;
      }
      key.then<void>((T key) {
        // 画像リソースに対応するkeyを取得
        obtainedKey = key;
        // keyに対応するImageStreamCompleterを取得し、キャッシュにない場合は渡されたloaderコールバックを呼び出して
        // 読み込み、キャッシュに追加します
        final ImageStreamCompleter completer = PaintingBinding
            .instance.imageCache
            .putIfAbsent(key, () => load(key), onError: handleError);
        if (completer != null) {
          stream.setCompleter(completer);
        }
      }).catchError(handleError);
    });
    return stream;
  }

  /// キャッシュから画像を削除し、戻り値がtrueの場合は削除成功を示します
  Future<bool> evict(
      {ImageCache cache,
      ImageConfiguration configuration = ImageConfiguration.empty}) async {
    cache ??= imageCache;
    final T key = await obtainKey(configuration);
    return cache.evict(key);
  }

  /// 対応する画像リソースkeyを取得し、具体的にはサブクラスによって実装されます
  Future<T> obtainKey(ImageConfiguration configuration);

  /// keyに基づいて画像を読み込み、ImageStreamCompleterに変換します。具体的にはサブクラスによって実装されます
  @protected
  ImageStreamCompleter load(T key);

  @override
  String toString() => '$runtimeType()';
}

resolveメソッド内で画像リソースを解析するためにPaintingBindingのシングルトンを使用して画像キャッシュimageCacheを取得し、putIfAbsentメソッドを呼び出します。ここでは LRU キャッシュの基本ロジックが実装されており、キャッシュがあるかどうかに応じて処理が行われます。キャッシュがある場合はキャッシュから対応する画像リソースを取得し、そうでない場合は渡されたloaderを呼び出して画像を読み込み、読み込んだ画像をキャッシュImageCacheに追加します。

次に、ネットワーク画像を最終的に読み込むImageProviderの実装クラスNetworkImageloadメソッドの実装を確認します:

@override
ImageStreamCompleter load(image_provider.NetworkImage key) {

    final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
    // _loadAsyncメソッド
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, chunkEvents),
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      informationCollector: () {
        return <DiagnosticsNode>[
          DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
          DiagnosticsProperty<image_provider.NetworkImage>('Image key', key),
        ];
      },
    );
}

loadメソッド内で_loadAsyncが呼び出され、これが実際に画像をダウンロードするメソッドであり、画像をデコードして返す必要があります。_loadAsyncメソッドのソースコードは以下の通りです:

/// 画像をダウンロードし、デコードします
Future<ui.Codec> _loadAsync(
    NetworkImage key,
    StreamController<ImageChunkEvent> chunkEvents,
    ) async {
  try {
    assert(key == this);

    final Uri resolved = Uri.base.resolve(key.url);
    final HttpClientRequest request = await _httpClient.getUrl(resolved);
    headers?.forEach((String name, String value) {
      request.headers.add(name, value);
    });
    final HttpClientResponse response = await request.close();
    if (response.statusCode != HttpStatus.ok)
      throw image_provider.NetworkImageLoadException(statusCode: response.statusCode, uri: resolved);

    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は空のファイルです: $resolved');
    // 画像をバイナリCodecオブジェクトにデコードします
    return PaintingBinding.instance.instantiateImageCodec(bytes);
  } finally {
    chunkEvents.close();
  }
}

画像をダウンロードすると、画像はバイナリに対応する Codec オブジェクトにデコードされます。この Codec オブジェクトは Flutter エンジン内のネイティブメソッドによってデコードされます。以下のように:

String _instantiateImageCodec(Uint8List list, _Callback<Codec> callback, _ImageInfo imageInfo, int targetWidth, int targetHeight)
  native 'instantiateImageCodec';

上記のプロセスで、画像は Flutter エンジン内のネイティブメソッドによってデコードされ、最終的にImageStreamCompleterが返されます。このImageStreamCompleterresolveメソッド内でImageStreamに設定され、resolveメソッドはこのImageStreamを返します。このImageStreamを通じて画像の読み込み進捗をリスニングできます。ImageStreamのソースコードは以下の通りです:

/// ImageStreamは画像リソースを処理するために使用され、画像リソースがまだ読み込まれていないことを示します。
/// 画像リソースが一度読み込まれると、ImageStreamの実際のデータオブジェクトはdart:ui.Imageとscaleで構成されるImageInfoになります。
class ImageStream extends Diagnosticable {
  ImageStream();

  /// 読み込み中の画像リソースを管理し、画像リソースの読み込みをリスニングします。成功、進行中、失敗など
  ImageStreamCompleter get completer => _completer;
  ImageStreamCompleter _completer;

  List<ImageStreamListener> _listeners;

  /// 画像読み込みリスナーを設定します。通常、ImageStreamを作成するImageProviderによって自動的に設定され、各ImageStreamには一度だけ設定できます。
  void setCompleter(ImageStreamCompleter value) {
    assert(_completer == null);
    _completer = value;
    if (_listeners != null) {
      final List<ImageStreamListener> initialListeners = _listeners;
      _listeners = null;
      initialListeners.forEach(_completer.addListener);
    }
  }

  /// 画像読み込みリスナーを追加します
  void addListener(ImageStreamListener listener) {
    if (_completer != null) return _completer.addListener(listener);
    _listeners ??= <ImageStreamListener>[];
    _listeners.add(listener);
  }

  /// 画像読み込みリスナーを削除します
  void removeListener(ImageStreamListener listener) {
    if (_completer != null) return _completer.removeListener(listener);
    assert(_listeners != null);
    for (int i = 0; i < _listeners.length; i += 1) {
      if (_listeners[i] == listener) {
        _listeners.removeAt(i);
        break;
      }
    }
  }

  Object get key => _completer ?? this;

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    // ...
  }
}

これで、画像リソースは最終的にImageStreamに変換されることがわかりました。resolveメソッドは Image コンポーネントの対応するライフサイクルメソッドで呼び出されます。例えば、didChangeDependenciesdidUpdateWidgetなどのメソッドで、コンポーネントが構築されるときにRawImageが作成され、ソースコードを追跡するとRenderImageが続きます。そのpaintメソッド内でpaintImageメソッドが呼び出され、canvas を通じて画像の設定情報が描画されます。

画像のプリロード#

Flutter ではprecacheImageメソッドを使用して画像をプリロードできます。つまり、画像を事前にキャッシュに追加し、画像を読み込む必要があるときに直接キャッシュから取得します。precacheImageメソッドは、ImageProviderresolveメソッドを通じて画像リソースを解析し、画像キャッシュに追加します。メソッドのソースコードは以下の通りです:

/// precacheImage
Future<void> precacheImage(
  ImageProvider provider,
  BuildContext context, {
  Size size,
  ImageErrorListener onError,
}) {
  final ImageConfiguration config = createLocalImageConfiguration(context, size: size);
  final Completer<void> completer = Completer<void>();
  // 画像リソースを解析し、キャッシュに追加します
  final ImageStream stream = provider.resolve(config);
  ImageStreamListener listener;
  listener = ImageStreamListener(
    // 省略...
    },
  );
  stream.addListener(listener);
  return completer.future;
}

使用時は画像のソースに応じて異なるImageProviderを選択し、対応する画像をキャッシュします。使用方法は以下の通りです:

// 画像をプリキャッシュ
precacheImage(new AssetImage("images/cat.jpg"), context);

画像のキャッシュ#

ImageCacheは Flutter が提供する LRU アルゴリズムに基づくキャッシュ実装で、デフォルトで 1000 枚の画像をキャッシュできます。最大キャッシュサイズは 100MB で、キャッシュがいずれかの制限を超えると、最近最少使用のキャッシュ項目が削除されます。もちろん、プロジェクトのニーズに応じて最大キャッシュ項目_maximumSizeの値と最大キャッシュサイズ_maximumSizeBytesの値を設定できます。具体的にはImageCacheのソースコード関連のコメントを確認してください。以下の通りです:

const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB

/// LRUを使用して実装された画像キャッシュ。最大100枚の画像、最大キャッシュサイズ100MB、キャッシュはImageProviderおよびそのサブクラスによって実装されます。
/// そのキャッシュインスタンスはPaintingBindingのシングルトンによって保持されます。
class ImageCache {
  // 読み込み中の画像キュー
  final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};

  // キャッシュキュー
  final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};

  /// キャッシュ項目の最大数
  int get maximumSize => _maximumSize;
  int _maximumSize = _kDefaultSize;

  /// キャッシュ項目の最大数を設定します
  set maximumSize(int value) {
    assert(value != null);
    assert(value >= 0);
    if (value == maximumSize) return;
    _maximumSize = value;
    if (maximumSize == 0) {
      clear();
    } else {
      _checkCacheSize();
    }
  }

  /// 現在のキャッシュ項目の数
  int get currentSize => _cache.length;

  /// 最大キャッシュサイズ(バイト)
  int get maximumSizeBytes => _maximumSizeBytes;
  int _maximumSizeBytes = _kDefaultSizeBytes;

  /// キャッシュサイズを設定します
  set maximumSizeBytes(int value) {
    assert(value != null);
    assert(value >= 0);
    if (value == _maximumSizeBytes) return;
    _maximumSizeBytes = value;
    if (_maximumSizeBytes == 0) {
      clear();
    } else {
      _checkCacheSize();
    }
  }

  /// 現在のキャッシュサイズ(バイト)
  int get currentSizeBytes => _currentSizeBytes;
  int _currentSizeBytes = 0;

  /// キャッシュをクリアします
  void clear() {
    _cache.clear();
    _pendingImages.clear();
    _currentSizeBytes = 0;
  }

  /// 対応するkeyを使用してキャッシュを削除し、削除成功の場合はtrueを返します。そうでない場合は、読み込みが完了していない画像も削除され、対応する画像の読み込みリスナーも削除されます。
  bool evict(Object key) {
    final _PendingImage pendingImage = _pendingImages.remove(key);
    if (pendingImage != null) {
      pendingImage.removeListener();
      return true;
    }
    final _CachedImage image = _cache.remove(key);
    if (image != null) {
      _currentSizeBytes -= image.sizeBytes;
      return true;
    }
    return false;
  }

  /// キャッシュAPIのエントリポイント
  ///
  /// キャッシュが利用可能な場合、指定されたkeyからキャッシュ内のImageStreamCompleterを取得し、そうでない場合は
  /// 提供されたコールバックloader()を使用してImageStreamCompleterを取得し、返します。どちらもkeyを最近使用された位置に移動します。
  ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(),
      {ImageErrorListener onError}) {
    assert(key != null);
    assert(loader != null);
    ImageStreamCompleter result = _pendingImages[key]?.completer;
    // 画像がまだ読み込まれていない場合、直接返します
    if (result != null) return result;
    // 対応するキャッシュがある場合、まずキャッシュから削除し、次に最近使用された位置に追加します
    final _CachedImage image = _cache.remove(key);
    if (image != null) {
      _cache[key] = image;
      return image.completer;
    }
    // 対応するキャッシュが取得できない場合、直接対応するImageProviderのloadメソッドを使用して画像を読み込みます
    try {
      result = loader();
    } catch (error, stackTrace) {
      if (onError != null) {
        onError(error, stackTrace);
        return null;
      } else {
        rethrow;
      }
    }
    void listener(ImageInfo info, bool syncCall) {
      // 読み込みに失敗した画像はキャッシュサイズを占有しません
      final int imageSize =
          info?.image == null ? 0 : info.image.height * info.image.width * 4;
      final _CachedImage image = _CachedImage(result, imageSize);
      // 画像のサイズがキャッシュサイズを超え、キャッシュサイズが0でない場合、キャッシュを画像キャッシュサイズまで小さくします
      if (maximumSizeBytes > 0 && imageSize > maximumSizeBytes) {
        _maximumSizeBytes = imageSize + 1000;
      }
      _currentSizeBytes += imageSize;
      // 読み込み中の画像キューから読み込まれた画像を削除し、削除リスナーを設定します
      final _PendingImage pendingImage = _pendingImages.remove(key);
      if (pendingImage != null) {
        pendingImage.removeListener();
      }
      // 読み込まれた画像をキャッシュに追加します
      _cache[key] = image;
      // キャッシュチェック、キャッシュ制限を超えた場合は最近最少使用のキャッシュ項目を削除します
      _checkCacheSize();
    }

    // 読み込み中の画像を_pendingImagesに追加し、画像の読み込みリスナーを設定します
    if (maximumSize > 0 && maximumSizeBytes > 0) {
      final ImageStreamListener streamListener = ImageStreamListener(listener);
      _pendingImages[key] = _PendingImage(result, streamListener);
      // リスナーは[_PendingImage.removeListener]で削除されます。
      result.addListener(streamListener);
    }
    return result;
  }

  // キャッシュチェック、キャッシュ制限を超えた場合は最近最少使用のキャッシュ項目を削除します
  void _checkCacheSize() {
    while (
        _currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
      final Object key = _cache.keys.first;
      final _CachedImage image = _cache[key];
      _currentSizeBytes -= image.sizeBytes;
      _cache.remove(key);
    }
    assert(_currentSizeBytes >= 0);
    assert(_cache.length <= maximumSize);
    assert(_currentSizeBytes <= maximumSizeBytes);
  }
}
// キャッシュ画像クラス
class _CachedImage {
  _CachedImage(this.completer, this.sizeBytes);

  final ImageStreamCompleter completer;
  final int sizeBytes;
}

// 読み込み中の画像クラス
class _PendingImage {
  _PendingImage(this.completer, this.listener);

  final ImageStreamCompleter completer;
  final ImageStreamListener listener;

  void removeListener() {
    completer.removeListener(listener);
  }
}

上記のコードは全体のキャッシュロジックです。resolveメソッドが呼び出されると、putIfAbsentメソッドが呼び出されます。このメソッドはキャッシュのエントリポイントであり、すでにキャッシュがある場合はキャッシュから取得し、そうでない場合は対応するImageProviderloadメソッドを呼び出して画像を読み込み、キャッシュに追加します。

画像キャッシュのクリア#

画像キャッシュをクリアするには、直接PaintingBindingのシングルトンを取得してImageCacheclearメソッドを呼び出します。以下の通りです:

/// キャッシュをクリア
_clearCache(BuildContext context) {
  PaintingBinding.instance.imageCache.clear();
  Toast.show("キャッシュがクリアされました", context);
}

画像の読み込み進捗のリスニング#

前述の通り、resolveメソッドは対応する画像のImageStreamを返します。このImageStreamを通じて画像の読み込みリスナーを設定できます。実際に追加されるのはImageStreamListenerです。以下の通りです:

/// Image
Image image = Image.network(
  "https://cdn.nlark.com/yuque/0/2019/jpeg/644330/1576812507787-bdaeaf42-8317-4e06-a489-251686bf7b91.jpeg",
  width: 100,
  height: 100,
  alignment: Alignment.topLeft,
);

// 画像読み込みリスナー
image.image.resolve(ImageConfiguration()).addListener(
        ImageStreamListener((ImageInfo imageInfo, bool synchronousCall) {
      completer.complete(imageInfo.image);
    }, onChunk: (event) {
      int currentLength = event.cumulativeBytesLoaded;
      int totalLength = event.expectedTotalBytes;
      print("$currentLength/$totalLength from network");
    }, onError: (e, trace) {
      print(e.toString());
    }));

開発中に最も一般的に使用されるのは、以下のようにloadingBuilderプロパティを使用して画像の読み込み進捗をリスニングする方法です。実際に設定されるのもImageStreamListenerです。以下の通りです:

/// 画像進捗リスナー
class ImageLoadListenerSamplePage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ImageState();
  }
}
/// _ImageState
class _ImageState extends State<ImageLoadListenerSamplePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("画像読み込みリスナー"),
        centerTitle: true,
      ),
      body: Image.network(
        "https://cdn.nlark.com/yuque/0/2019/jpeg/644330/1576812507787-bdaeaf42-8317-4e06-a489-251686bf7b91.jpeg",
        width: 100,
        height: 100,
        loadingBuilder: (BuildContext context, Widget child,
            ImageChunkEvent loadingProgress) {
          if (loadingProgress == null) return child;
          int currentLength = loadingProgress.cumulativeBytesLoaded;
          int totalLength = loadingProgress.expectedTotalBytes;
          print("$currentLength/$totalLength from network");
          return CircularProgressIndicator(
            value: loadingProgress.expectedTotalBytes != null
                ? loadingProgress.cumulativeBytesLoaded /
                    loadingProgress.expectedTotalBytes
                : null,
          );
        },
      ),
    );
  }
}

画像読み込みのサンプル#

前述の通り、Flutter はデフォルトでネットワーク、SD カード、アセット、メモリ内の画像を読み込む機能を実装しています。SD カードやメモリから画像を取得する際には FutureBuilder を使用して非同期タスクを処理し、Image を返します。詳細はコードを直接確認してください:

/// 画像を読み込む
class ImageLoadSamplePage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ImageSampleSate();
  }
}
/// _ImageSampleSate
class _ImageSampleSate extends State<ImageLoadSamplePage> {

  Future<Directory> _externalStorageDirectory;
  Future<Uint8List> _imageUint8List;

  /// ファイルディレクトリを取得
  void _requestExternalStorageDirectory() {
    setState(() {
      _externalStorageDirectory = getExternalStorageDirectory();
    });
  }

  /// ファイルをバイトに変換
  void _requestBytes() {
    setState(() {
      File file = new File("/storage/emulated/0/owl.jpg");
      _imageUint8List = file.readAsBytes();
    });
  }

  @override
  Widget build(BuildContext context) {
  
    _requestExternalStorageDirectory();
    _requestBytes();

    return Scaffold(
      appBar: AppBar(
        title: Text("画像サンプル"),
        centerTitle: true,
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _clearCache(context);
        },
        child: Icon(Icons.clear),
      ),
      body: ListView(
        scrollDirection: Axis.vertical,
        children: <Widget>[
          Text(
            "ネットワークから...",
            style: TextStyle(fontSize: 16),
          ),
          Image.network(
            "https://cdn.nlark.com/yuque/0/2019/jpeg/644330/1576812507787-bdaeaf42-8317-4e06-a489-251686bf7b91.jpeg",
            width: 100,
            height: 100,
            alignment: Alignment.topLeft,
          ),
          Text(
            "ファイルから...",
            style: TextStyle(fontSize: 16),
          ),
          FutureBuilder<Directory>(
            future: _externalStorageDirectory,
            builder: _buildFileDirectory,
          ),
          Text(
            "アセットから...",
            style: TextStyle(fontSize: 16),
          ),
          Image.asset(
            'images/cat.jpg',
            width: 100,
            height: 100,
            alignment: Alignment.topLeft,
          ),
          Text(
            "メモリから...",
            style: TextStyle(fontSize: 16),
          ),
          FutureBuilder<Uint8List>(
            future: _imageUint8List,
            builder: _buildMemoryDirectory,
          ),
        ],
      ),
    );
  }

  /// 非同期でSDカード画像を取得
  Widget _buildFileDirectory(
      BuildContext context, AsyncSnapshot<Directory> snapshot) {
    Text text = new Text("デフォルト");
    if (snapshot.connectionState == ConnectionState.done) {
      if (snapshot.hasData) {
        File file = new File("${snapshot.data.path}/owl.jpg");
        return Image.file(
          file,
          width: 100,
          height: 100,
          alignment: Alignment.topLeft,
        );
      } else if (snapshot.hasError) {
        text = new Text(snapshot.error);
      } else {
        text = const Text("不明");
      }
    }
    print(text.data);
    return text;
  }


  /// 非同期でメモリ内画像を取得
  Widget _buildMemoryDirectory(
      BuildContext context, AsyncSnapshot<Uint8List> snapshot) {
    Text text = new Text("デフォルト");
    if (snapshot.connectionState == ConnectionState.done) {
      if (snapshot.hasData) {
        return Image.memory(
          snapshot.data,
          width: 100,
          height: 100,
          alignment: Alignment.topLeft,
        );
      } else if (snapshot.hasError) {
        text = new Text(snapshot.error);
      } else {
        text = const Text("不明");
      }
    }
    return text;
  }

  /// キャッシュをクリア(キャッシュテストのため)
  _clearCache(BuildContext context) {
    PaintingBinding.instance.imageCache.clear();
    print("---_clearCache-->");
    Toast.show("キャッシュがクリアされました", context);
  }
}

上記のコードの実行結果は以下の通りです:

image

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。