CachedNetworkImageのcache読み込みを23倍高速化する方法

by dicekest,

CachedNetworkImageとは

インターネットから画像を表示してくれるWidgetで、一度表示したものはデータをローカルのストレージに保存して次回の表示を早くしてくれます。 flutter_cache_manager に依存していてdefault設定だと sqflite を使ってDBに保存される実装となっています。

https://pub.dev/packages/cached_network_image

cacheの保存先は変更できる?

公式のドキュメントには保存先のストレージに関する設定は利用側でカスタムができると記載されていますし、SQLiteよりも高速なDBを使いたいというissueもあり cache_info_repository のインターフェースをoverrideしてカスタムすれば良いということがわかります。

The cache manager is customizable by creating a new CacheManager. It is very important to not create more than 1 CacheManager instance with the same key as these bite each other. In the example down here the manager is created as a Singleton, but you could also use for example Provider to Provide a CacheManager on the top level of your app. Below is an example with other settings for the maximum age of files, maximum number of objects and a custom FileService. The key parameter in the constructor is mandatory, all other variables are optional.

https://github.com/Baseflow/flutter_cache_manager/issues/121#issuecomment-1416211382

先に結果

今回はdart製のDBということでhive, Isar(hive version2)を比較対象として検証してみました。 インターフェースを調べている中で flutter_cache_manager がJSON実装も用意しているということがわかったので公式実装2つ、dart DB2つになっています。

結果としてはデフォルトのSQLiteでのキャッシュから読み込む時間の平均とJSONに保存して読み込みする時間の平均を比較すると約23倍速くなるということがわかりました。 Isarがhiveと同等もしくはそれ以上に高速であって欲しいというのが事前に期待していたところでしたがhiveの方が圧倒的に高速でした。。。

より高速にキャッシュにアクセスしたいという場合には JsonCacheInfoRepository のインターフェース に設定を切り替えてみたり、Isar,hiveあたりのdart製DBにしてみるのも高速化するので参考にしてみてください。

db read average read max
SQLite 109.9ms 263ms
isar 25.3ms 132ms
hive 6ms 18ms
JSON 4.6ms 11ms

※アプリケーション内で画像読み込みを500件ほどサンプリングしたデータで比較してます
※read maxはサンプリングした中で一番時間がかかった読み込みを記載しています。一画面で大量の読み込みが走るような場合に同時読み込み数に応じて読み込み時間が極端に悪化するということがあります。

https://pub.dev/packages/isar https://pub.dev/packages/hive https://github.com/Baseflow/flutter_cache_manager/blob/develop/flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart

JSONにストレージを切り替えるサンプル

JSONに保存するためのインターフェースはすでに用意されているのでiOS/Androidの場合に使われるように CacheManagerを下記のように用意する必要があります。 ここで指定する JsonCacheInfoRepository がアプリの領域にJSONファイルを設置して中にキャッシュ情報のメタデータなどを保存してくれます。

class JsonCacheManager extends CacheManager with ImageCacheManager {
  static const key = 'libCachedImageData';

  static final DefaultCacheManager _instance = DefaultCacheManager._();
  factory DefaultCacheManager() {
    return _instance;
  }

  JsonCacheManager._()
      : super(
          Config(
            key,
            repo: JsonCacheInfoRepository(),
          ),
        );
}

そしてこのCacheManagerをCachedNetworkImageを使っている箇所で設定するだけで有効になります。

CachedNetworkImage(
  imageUrl: url,
  cacheManager: JsonCacheManager(),
);

他のストレージに保存できるようにしたい場合

Isar/hiveなどの他のDBに対してキャッシュを生成したい場合には CacheInfoRepository を継承して JsonCacheInfoRepository と同等の実装をoverrideして用意する必要があります。 また必要に応じてキャッシュ情報のオブジェクト CacheObject とDBのレコードをマッピングするためのクラスを用意する必要があるかもしれません。 Isarの例にした場合IsarのDB用にスキーマを事前に定義する必要があるため CacheObject とのマッピングについても紹介します。

Isar用のCacheInfoRepositoryの作成

CacheInfoRepository に定義されているインターフェースに対してIsarのCRUDを用意していく流れになります。各メソッドの実装の詳細は長くなるので割愛しますが、キャッシュを保存して読み込む、不要になったら消すが実装されていると思ってください。

(というかカスタムできると記載されているのにインターフェースがexportされていないのは何故なんでしょうか・・?

import 'package:clock/clock.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/storage/cache_info_repositories/helper_methods.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';

import './image_cache_object.dart';

class IsarCacheInfoRepository extends CacheInfoRepository
    with CacheInfoRepositoryHelperMethods {
  Isar? box;
  @override
  Future<bool> open() async {
    if (!shouldOpenOnNewConnection()) {
      return openCompleter!.future;
    }
    final dir = await getApplicationDocumentsDirectory();
    box = await Isar.open([ImageCacheObjectSchema], directory: dir.path);
    return opened();
  }

  @override
  Future<CacheObject?> get(String key) async {
    try {
      final id = ImageCacheObject.fastHash(key);
      final cache = await box!.imageCacheObjects.get(id);
      if (cache == null) {
        return null;
      }
      return CacheObject.fromMap(cache.toJson());
    } on Exception catch (e) {
      print(e);
    }
    return null;
  }

  @override
  Future<List<CacheObject>> getAllObjects() async {
    final allCache =
        await box!.collection<ImageCacheObject>().where().findAll();
    return allCache.map((e) => CacheObject.fromMap(e.toJson())).toList();
  }

  @override
  Future<CacheObject> insert(
    CacheObject cacheObject, {
    bool setTouchedToNow = true,
  }) async {
    await box!.writeTxn(() async {
      await box!.imageCacheObjects.put(
        ImageCacheObject.fromCacheObject(
          cacheObject,
          setTouchedToNow: setTouchedToNow,
        ),
      );
    });
    return cacheObject;
  }

  @override
  Future<int> update(
    CacheObject cacheObject, {
    bool setTouchedToNow = true,
  }) async {
    await box!.writeTxn(() async {
      await box!.imageCacheObjects.put(
        ImageCacheObject.fromCacheObject(cacheObject,
            setTouchedToNow: setTouchedToNow),
      );
    });
    return 1;
  }

  @override
  Future updateOrInsert(CacheObject cacheObject) {
    return cacheObject.id == null ? insert(cacheObject) : update(cacheObject);
  }

  @override
  Future<List<CacheObject>> getObjectsOverCapacity(int capacity) async {
    final allSorted = await getAllObjects()
      ..sort((c1, c2) => c1.touched!.compareTo(c2.touched!));
    if (allSorted.length <= capacity) {
      return [];
    }
    return allSorted.getRange(0, allSorted.length - capacity).toList();
  }

  @override
  Future<bool> close() async {
    if (!shouldClose()) {
      return false;
    }
    await box!.close();
    return true;
  }

  @override
  Future<int> delete(int id) async {
    await box!.writeTxn(() async {
      await box!.imageCacheObjects.delete(id);
    });
    return 1;
  }

  @override
  Future<int> deleteAll(Iterable<int> ids) async {
    var deleted = 0;
    for (final id in ids) {
      deleted += await delete(id);
    }
    return deleted;
  }

  @override
  Future<void> deleteDataFile() {
    return box!.clear();
  }

  @override
  Future<bool> exists() {
    return box!.imageCacheObjects
        .where()
        .findAll()
        .then((value) => value.isNotEmpty);
  }

  @override
  Future<List<CacheObject>> getOldObjects(Duration maxAge) async {
    final then = clock.now().subtract(maxAge);

    final imageCache =
        await box!.imageCacheObjects.filter().touchedLessThan(then).findAll();
    return imageCache.map((e) => CacheObject.fromMap(e.toJson())).toList();
  }
}

repositoryのヘルパークラス

DBの初期化処理、クローズ処理を書く時に上のサンプルでも利用していますが 下記のヘルパークラスの実装を利用するとより簡単に実装できます。

ただ、close()などは実際に呼ばれることはなさそう?なのでアプリの実行中にどのタイミングで呼ばれるのかあまり自信はありません。

https://github.com/Baseflow/flutter_cache_manager/blob/master/flutter_cache_manager/lib/src/storage/cache_info_repositories/helper_methods.dart

CacheObjectとのマッピング

Isarを利用する場合に事前にテーブルスキーマを作成しておく必要があるため、 flutter_cache_managerがキャッシュとして管理している情報をIsarのスキーマとして定義します。

ここで注意するのが、Isarのテーブルのプライマリーキーになるものは ID 型(intのエイリアス)である必要があるということです。 flutter_cache_manager がCacheObjectを保存するときにはint型のキーがセットされているわけではありません。 Stringのパラメタからintに変換して保存する必要があります。

Isarのリポジトリを調べるとStringのパラメタをキーに設定したい場合に高速Hashに変換して保存する方法が紹介されています。 今回はそちらの実装を利用しつつ cache のキーとして設定することとします。

高速Hashは下記の部分です。

  /// FNV-1a 64bit hash algorithm optimized for Dart Strings
  static int fastHash(String string) {
    // ignore: avoid_js_rounded_ints
    var hash = 0xcbf29ce484222325;

    var i = 0;
    while (i < string.length) {
      final codeUnit = string.codeUnitAt(i++);
      hash ^= codeUnit >> 8;
      hash *= 0x100000001b3;
      hash ^= codeUnit & 0xFF;
      hash *= 0x100000001b3;
    }

    return hash;
  }

https://isar.dev/recipes/string_ids.html

最終的な Isar用のキャッシュスキーマは下記のようになりました。

import 'package:clock/clock.dart';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
import 'package:isar/isar.dart';

part 'image_cache_object.g.dart';

@collection
class ImageCacheObject {
  ImageCacheObject({
    required this.id,
    required this.url,
    required this.key,
    required this.relativePath,
    required this.validTill,
    this.eTag,
    this.length,
    this.touched,
  });
  factory ImageCacheObject.fromCacheObject(
    CacheObject cache, {
    bool setTouchedToNow = true,
  }) {
    return ImageCacheObject(
      id: cache.key,
      url: cache.url,
      key: cache.key,
      relativePath: cache.relativePath,
      validTill: cache.validTill,
      touched: setTouchedToNow ? clock.now() : cache.touched ?? clock.now(),
    );
  }

  /// Internal ID used to represent this cache object
  String? id;
  Id get isarId => fastHash(id!);

  /// The URL that was used to download the file
  String url;

  /// The key used to identify the object in the cache.
  ///
  /// This key is optional and will default to [url] if not specified
  String key;

  /// Where the cached file is stored
  String relativePath;

  /// When this cached item becomes invalid
  DateTime validTill;

  /// eTag provided by the server for cache expiry
  String? eTag;

  /// The length of the cached file
  int? length;

  /// When the file is last used
  DateTime? touched;

  /// FNV-1a 64bit hash algorithm optimized for Dart Strings
  /// https://isar.dev/recipes/string_ids.html
  static int fastHash(String string) {
    // ignore: avoid_js_rounded_ints
    var hash = 0xcbf29ce484222325;

    var i = 0;
    while (i < string.length) {
      final codeUnit = string.codeUnitAt(i++);
      hash ^= codeUnit >> 8;
      hash *= 0x100000001b3;
      hash ^= codeUnit & 0xFF;
      hash *= 0x100000001b3;
    }

    return hash;
  }

  Map<String, dynamic> toJson() {
    return {
      CacheObject.columnId: isarId,
      CacheObject.columnUrl: url,
      CacheObject.columnKey: id,
      CacheObject.columnPath: relativePath,
      CacheObject.columnValidTill: validTill.millisecondsSinceEpoch,
      if (eTag != null) CacheObject.columnETag: eTag,
      if (length != null) CacheObject.columnLength: length,
      if (touched != null)
        CacheObject.columnTouched: touched!.millisecondsSinceEpoch,
    };
  }
}

まとめ

CachedNetworkImage の保存先ストレージが自由に変更可能で、DBによっては高速化させることができるということがわかりました。 今回は調査しませんでしたが、読み込んだ画像ファイル自体もキャッシュする先を変更できることもできるなど知らなかった実装を深掘りすることができました。 すでにアプリケーションでDBを利用している場合はストレージを1つにまとめたり、高速化するためにhiveやJSON実装に変更してみるということも選択肢になるかもしれません。