🦐

【Flutter】ネットワークから取得した画像をキャッシュして表示を高速化する【cached_network_image】

2022/06/16に公開

はじめに

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 とは

https://pub.dev/packages/cached_network_image

ネットワークから取得した画像をキャッシュしてくれるパッケージです。

サンプルアプリを作りました。cached_network_image を使ってキャッシュから表示している左側のほうが表示が速いのがわかると思います。これは必須パッケージですね!

キャッシュあり キャッシュ無し
キャッシュあり キャッシュ無し

このサンプルアプリは下記で公開しています!

https://github.com/susatthi/flutter-sample-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 )がキャッシュしてくれているので表示は高速でした。非サポートでも問題なさそうですね!

Web

使い方

それでは、cached_network_image の使い方を紹介します!

pubspec.yaml を編集する

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_providergetTemporaryDirectory() が見当たらないというエラーが出ました。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 を使って代替画像を返すようにモック化した DefaultCacheManagerCachedNetworkImagecacheManager プロパティに与えてあげます。

   ・・・
        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,
+    );
+  }
+}

テストを実行してみると、、、今度は無事テストが成功しました 🎉

コードは公開しています

今回作成したサンプルアプリ(実装とテストコード)は下記で公開しています!

https://github.com/susatthi/flutter-sample-cached-network-image

最後に

この CachedNetworkImage の Widget テストが出来ず、ずっとカバレッジ率が 99% だったのですが、やっと解決策が見つかって、めでたく 100% になりました!完全にカバレッジ率を 100% にするだけのゲームになっちゃってますが、スッキリしました〜

Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!

https://flutteruniv.com?invite_id=9hsdZHg0qtaMIr6RPRulAaRJfA83

あわせて読みたい

https://zenn.dev/susatthi/articles/20220613-055442-flutter-isar-test

Discussion