CachedNetworkImageのcache読み込みを23倍高速化する方法
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()などは実際に呼ばれることはなさそう?なのでアプリの実行中にどのタイミングで呼ばれるのかあまり自信はありません。
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実装に変更してみるということも選択肢になるかもしれません。