【Flutter】ネットワークから取得した画像をキャッシュして表示を高速化する【cached_network_image】
はじめに
cached_network_image パッケージを使ってネットワークから取得した画像をキャッシュして、2 回目以降の表示を高速化する実装方法を紹介します!あと Widget テストの実装方法も紹介します。参考になれば幸いです!
環境
Flutter 3.0.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision fb57da5f94 (3 weeks ago) • 2022-05-19 15:50:29 -0700
Engine • revision caaafc5604
Tools • Dart 2.17.1 • DevTools 2.12.2
cached_network_image とは
ネットワークから取得した画像をキャッシュしてくれるパッケージです。
サンプルアプリを作りました。cached_network_image を使ってキャッシュから表示している左側のほうが表示が速いのがわかると思います。これは必須パッケージですね!
キャッシュあり | キャッシュ無し |
---|---|
このサンプルアプリは下記で公開しています!
キャッシュの仕組み
http を使ってネットワークから取得した画像を path_provider によって提供されたアプリの一時ディレクトリに file を利用して保存しています。ファイルに関する情報は sqflite を使用してデータベースに保存しています。
これらのキャッシュ機能は flutter_cache_manager を利用して実現しています。cacheManager
プロパティを与えることで、ファイルの最大経過時間、キャッシュする画像の最大数、ファイル保存先や保存方法の変更などをカスタマイズできます。
このように、cached_network_image は内部で多くのパッケージを利用しています。後々紹介しますがこれがテストを難しくしている要因です。
サポートするプラットフォーム
次の通り、サポートするプラットフォームは Android, iOS, macOS の 3 つだけです。
非サポートの Web がどう動くのか確認してみました( Windows と Linux はごめんなさい)。
Web の動き
確かに非サポートで、常に HTTP リクエストしていました。しかし、ブラウザ( Chrome と Safari )がキャッシュしてくれているので表示は高速でした。非サポートでも問題なさそうですね!
使い方
それでは、cached_network_image の使い方を紹介します!
pubspec.yaml を編集する
dependencies:
+ cached_network_image: ^3.2.1
flutter:
sdk: flutter
実装
実装方法は URL を渡してあげるだけ、とてもシンプルです!
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
),
いろいろカスタマイズができます!
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
+ placeholder: (context, url) => const Center(
+ child: CircularProgressIndicator(),
+ ),
),
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
+ progressIndicatorBuilder: (context, url, downloadProgress) =>
+ CircularProgressIndicator(value: downloadProgress.progress),
),
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
+ errorWidget: (context, url, dynamic error) => const Icon(Icons.error),
),
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
+ imageBuilder: (context, imageProvider) => Image(image: imageProvider),
),
Widget テストの方法
CachedNetworkImage
を使って Widget テストをするとエラーがたくさん出てきます。その解決方法を紹介します!
結論
次の 3 点をやれば OK です!
- 画像が読み込まれるまで待つ
- テスト全体を
runAsync()
で括る -
cacheManager
をモック化する
CachedNetworkImage
に対して、「画像が表示されるはず」という Widget テストのコード全体がこちらです。
「画像が表示されるはず」という Widget テストコード全体
// ignore_for_file: depend_on_referenced_packages
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file/local.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
void main() {
testWidgets('画像が表示されるはず', (tester) async {
await tester.runAsync(() async {
ImageProvider? receiveImageProvider;
await tester.pumpWidget(
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
cacheManager: MockCacheManager(),
imageBuilder: (context, imageProvider) {
receiveImageProvider = imageProvider;
return Image(
image: imageProvider,
);
},
),
);
// 画像が読み込まれるまで待つ
await Future<void>.delayed(const Duration(seconds: 1));
await tester.pump();
expect(receiveImageProvider, isNotNull);
});
});
}
class MockCacheManager extends Mock implements DefaultCacheManager {
static const fileSystem = LocalFileSystem();
Stream<FileResponse> getImageFile(
String url, {
String? key,
Map<String, String>? headers,
bool withProgress = false,
int? maxHeight,
int? maxWidth,
}) async* {
yield FileInfo(
// ローカルのファイルを読み込んで返す
fileSystem.file('./test/assets/13707135.png'),
FileSource.Cache,
DateTime(2050),
url,
);
}
}
詳細
「画像が表示されるはず」という Widget テストを題材にして上記で上げた 3 点について詳しくみていきます。
最初に pumpWidget()
で Widget を立ち上げて、imageBuilder
が実行されて画像が表示されたかどうかをテストするテストコードを書いてみます。
testWidgets('画像が表示されるはず', (tester) async {
ImageProvider? receiveImageProvider;
await tester.pumpWidget(
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
imageBuilder: (context, imageProvider) {
receiveImageProvider = imageProvider;
return Image(
image: imageProvider,
);
},
),
);
expect(receiveImageProvider, isNotNull);
});
このテストを実行すると、次の通り失敗します。画像を読み込む前にテストをしちゃっているためです。画像が読み込み終わるまで待つ必要があります。
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: not null
Actual: <null>
When the exception was thrown, this was the stack:
#4 main.<anonymous closure> (file:///Users/susa/Develop/flutter-sample-cached-network-image/test/cached_network_image_test.dart:24:5)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)
画像が読み込まれるまで待つ
次のように画像が読み込まれるまで待つ処理を入れてみました。
testWidgets('画像が表示されるはず', (tester) async {
ImageProvider? receiveImageProvider;
await tester.pumpWidget(
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
imageBuilder: (context, imageProvider) {
receiveImageProvider = imageProvider;
return Image(
image: imageProvider,
);
},
),
);
+ // 画像が読み込まれるまで待つ
+ await Future<void>.delayed(const Duration(seconds: 1));
+ await tester.pump();
expect(receiveImageProvider, isNotNull);
});
テストを実行すると、、、、今度はずっと実行しっぱなしになって一向にテストが終わらない現象が起きます。
Widget テストが終わらない
原因は、Widget テストは非同期処理が進まないようになっている ので、await Future<void>.delayed()
で処理が止まってしまうためです。ちなみに、単体テストは非同期処理が進みます。
runAsync()
で括る
テスト全体を 解決方法は、次のように runAsync()
で全体を括ってあげることです。CachedNetworkImage
内部でも非同期処理があるので全体を括ります。
testWidgets('画像が表示されるはず', (tester) async {
+ await tester.runAsync(() async {
ImageProvider? receiveImageProvider;
await tester.pumpWidget(
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
imageBuilder: (context, imageProvider) {
receiveImageProvider = imageProvider;
return Image(
image: imageProvider,
);
},
),
);
// 画像が読み込まれるまで待つ
await Future<void>.delayed(const Duration(seconds: 1));
await tester.pump();
expect(receiveImageProvider, isNotNull);
+ });
});
もう一度テストを実行してみましょう。
すると、今度は path_provider
の getTemporaryDirectory()
が見当たらないというエラーが出ました。path_provider
パッケージはそのままテストでは使えません。
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following MissingPluginException was thrown running a test:
MissingPluginException(No implementation found for method getTemporaryDirectory on channel
plugins.flutter.io/path_provider_macos)
When the exception was thrown, this was the stack:
#0 MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:165:7)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)
The test description was:
画像が表示されるはず
さっそく path_provider
をモック実装して、、、、、、とやると実は沼にはまっていきます。path_provider
をモック化すると、次に sqflite 関連のエラーが出て、解決から遠ざかっていきます。
ここは一歩下がって CachedNetworkImage
の動きを確認してみます。CachedNetworkImage
はざっくり次のように動きます。
1️⃣ URLを受け取り、キャッシュに画像があれば表示する
2️⃣ ネットワークから画像を取得する
3️⃣ キャッシュに保存する
4️⃣ 画像を表示する
ここで強強エンジニアなら、常にキャッシュに画像がある体で画像を返してしまえばネットワークにも取りに行かないしキャッシュへ保存することもないのでよさそう、と秒で気づきます(私は気づくのに 5 時間かかりましたが。。。)。
cacheManager
をモック化する
キャッシュに画像があるかのように詐称するには cacheManager
プロパティを使います。
mocktail を使って代替画像を返すようにモック化した DefaultCacheManager
をCachedNetworkImage
の cacheManager
プロパティに与えてあげます。
・・・
CachedNetworkImage(
imageUrl: 'https://avatars.githubusercontent.com/u/13707135?v=4',
+ cacheManager: MockCacheManager(),
imageBuilder: (context, imageProvider) {
receiveImageProvider = imageProvider;
return Image(
image: imageProvider,
);
},
),
・・・
+class MockCacheManager extends Mock implements DefaultCacheManager {
+ static const fileSystem = LocalFileSystem();
+
+
+ Stream<FileResponse> getImageFile(
+ String url, {
+ String? key,
+ Map<String, String>? headers,
+ bool withProgress = false,
+ int? maxHeight,
+ int? maxWidth,
+ }) async* {
+ yield FileInfo(
+ // ローカルのファイルを読み込んで返す
+ fileSystem.file('./test/assets/13707135.png'),
+ FileSource.Cache,
+ DateTime(2050),
+ url,
+ );
+ }
+}
テストを実行してみると、、、今度は無事テストが成功しました 🎉
コードは公開しています
今回作成したサンプルアプリ(実装とテストコード)は下記で公開しています!
最後に
この CachedNetworkImage
の Widget テストが出来ず、ずっとカバレッジ率が 99% だったのですが、やっと解決策が見つかって、めでたく 100% になりました!完全にカバレッジ率を 100% にするだけのゲームになっちゃってますが、スッキリしました〜
Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!
あわせて読みたい
Discussion