メインコンテンツへスキップ
見出し画像

Flutter で写真の表示待ちを3段で減らす。CachedNetworkImage の cacheKey は「写真を替えたら必ず変わる値」に

    Flutter で Firebase Storage の写真を CachedNetworkImage で出すとき、cacheKey を渡すと、URL はキャッシュの見分けに使われなくなります。キーが同じなら、写真を差し替えても、端末に残った前の写真が出続けます。

    この記事は、Firebase Storage の写真を Flutter で表示している個人開発者に向けて、表示を速くする3つの手と、キャッシュのキーの決め方を、手元の5アプリの実例でまとめたものです。書くためにコードを読み直したら、おもちゃ帳にもこの形の穴が1つ見つかりました。

    写真が1枚出るまでに、3つの待ちがある


    画像
    写真が出るまでの3つの待ちと、それぞれに入れた手

    Firestore には写真のパスだけを保存し、本体は Storage に置く作りだと、写真が1枚出るまでに次の3つを待ちます。

    1. パスからダウンロード URL を聞く(getDownloadURL() の往復)

    2. URL から画像を落とす

    3. 大きな画像を読み込み、画面の大きさに縮めて描く

    Flutter の Image.network(NetworkImage)は、読んだ画像をメモリにしか持ちません。アプリを閉じると忘れるので、開き直すたびに落とし直します。URL も残していなければ、1 からやり直しです。おもちゃ帳では、3つの待ちそれぞれに手を入れました(1 と 2 は7月、3 は10月)。

    手1: ダウンロード URL は端末に残して、先に出す

    URL はパスごとに SharedPreferences に残し、次からはまずそれを使います。そのあと Storage に最新の URL を聞き、違ったときだけ差し替えます。Riverpod の StreamProvider で書くと短く済みます。

    final iconDownloadUrlProvider =
        StreamProvider.family<String, String>((ref, path) async* {
      final cache = ref.watch(localImageUrlCacheProvider);
      final cachedUrl = await cache.read(path);
      if (cachedUrl != null) yield cachedUrl; // 残しておいた URL をまず出す
    
      final freshUrl =
          await ref.watch(imageRepositoryProvider).getDownloadUrl(path);
      if (freshUrl != cachedUrl) {            // 違うときだけ流し直す
        await cache.write(path, freshUrl);
        yield freshUrl;
      }
    });

    手2: 画像は CachedNetworkImage でディスクに残す

    cached_network_image は、落とした画像を端末のディスクにも残します。開き直しても通信を待たずに出せます。

    CachedNetworkImage(
      imageUrl: url,
      cacheKey: photoCacheKey(path, photoUpdatedAt),
      fit: BoxFit.cover,
    )
    
    String photoCacheKey(String path, DateTime? photoUpdatedAt) =>
        photoUpdatedAt == null
            ? path
            : '$path@${photoUpdatedAt.millisecondsSinceEpoch}';

    キーに URL を使わないのは、手1で URL が2回流れてくることがあるからです(残しておいた URL と最新の URL)。ダウンロード URL にはトークンが付いていて、写真の中身と1対1ではありません。そこで、パスに「写真を替えた日時」(photoUpdatedAt。Firestore に serverTimestamp() で書く)を足してキーにしました。

    手3: 写真は上げる前に縮める

    撮ったままの写真は1枚で数 MB あり、落とすのも描くのも重くなります。image_picker なら、選ぶときに縮められます。

    final file = await ImagePicker().pickImage(
      source: ImageSource.gallery,
      maxWidth: 1600, // 子どものアイコンは 800
      maxHeight: 1600,
      imageQuality: 85,
    );

    縦横比は保たれ、元より小さい写真は拡大されません。おもちゃ帳では、あわせて Storage のルールでも1枚 10MB 未満に絞りました。

    cacheKey を渡すと、URL は比べられなくなる

    ここからが落とし穴です。cached_network_image 3.4.1 の画像プロバイダは、同じ画像かどうかをこう決めています。

    // CachedNetworkImageProvider の ==(抜粋)
    (cacheKey ?? url) == (other.cacheKey ?? other.url)

    cacheKey を渡すと、URL が変わっても同じ画像として扱われます。メモリのキャッシュも、ディスクのキャッシュ(flutter_cache_manager)も、このキーで探します。

    flutter_cache_manager は、キーに当たるファイルがあればまずそれを出し、期限を過ぎていたときだけ URL から取り直します。期限は受け取ったときの Cache-Control の max-age で、なければ7日です。max-age=0 も「0 より大きいときだけ読む」書き方なので、7日として扱われます。

    つまり、キーが変わらないかぎり、写真を差し替えても期限まで前の写真が出ます。キーにするものを並べると、こうなります。


    画像

    「写真を替えた」をパスの変化で見ると、2枚目から古いまま

    おもちゃ帳では、photoUpdatedAt を「パスが変わったときだけ」更新していました。名前やメモを直しただけで写真を取り直さないためです。

    final photoPathChanged =
        (existingData?['photoPath'] as String?) != belonging.photoPath;
    // ...
    'photoUpdatedAt': photoPathChanged
        ? FieldValue.serverTimestamp()
        : existingData?['photoUpdatedAt'],

    ところが、写真の置き場所は …/belongings/{id}/photo.jpg という決まった名前で、差し替えは同じパスへの上書きです。1枚目を付けたときはパスが「なし」から変わるので日時も更新されますが、2枚目からはパスが変わらず、日時もキーもそのままになります。


    画像
    1枚目を付けたときはキーが変わるが、差し替えではキーが変わらない

    テストは通っていました。リポジトリのテストでは、差し替えを new-photo.jpg という別のパスで書いていたからです。アプリは同じパスに上書きするので、テストとアプリが違う道を通っていました。Firestore のルールのテストでも、テストがアプリと違う書き方をしていて通っていたことがあり、同じ形です。

    持ち物・欲しいもの・子どものアイコンの3つとも同じ書き方で、表示の9か所すべてがこのキーを使っています。コードを読んで見つけたもので、まだ直しておらず、端末での再現もこれからです。

    直すなら、写真を上げたら必ず日時を更新する

    直し方は3つ考えられます。

    • 写真を上げたことを、呼び出し側から渡す: 保存の画面は写真を選び直したかを知っています。リポジトリに「写真を替えた」を渡し、そのときは必ず serverTimestamp() を書きます。テストは、アプリと同じく同じパスに上げ直す形で書きます

    • 上げるたびにファイル名を変える: パスが変わるので、今の判定のままで済みます。古いファイルを消す処理と、Storage のルールの置き場所の書き換えが要ります

    • 上げたあとに CachedNetworkImage.evictFromCache() で消す: 効くのは上げた端末だけで、家族のほかの端末には前の写真が残ります。さらに 3.4.1 では、ディスクは cacheKey で消しますが、メモリは URL だけで作ったプロバイダで消すので、cacheKey を付けた画像はメモリに残ります

    家族で共有するアプリなので、ほかの端末にも伝わる1つ目で直すつもりです。キャッシュは消すより、キーを変えるほうが確実です。

    同じ形は「キーの元」と「決まったファイル名」で探す

    探すときは、キャッシュのキーを作っている所と、決まった名前で上げている所を grep します。

    grep -rn "cacheKey:" lib/
    grep -rnE "/[a-z_]+\.(jpg|png)'" lib/

    見つけたら、キーの元になる値が「写真を上げたとき」に必ず変わるかを1つずつ確かめます。11月5日に手元の5アプリを見た結果です。


    画像

    とっておき帳(思い出の写真)は、上げるたびにパスごと変わるので、URL をキーにしたままで差し替えが伝わります。.333 はディスクに残さず、上げた直後にメモリから消して URL を取り直します。期限の問題がなく、プロフィール画像は本人しか見ないので、これで足ります。いちばん手間のかからない形は、とっておき帳の「上げるたびに名前を変える」でした。

    まとめ

    • 写真の待ちは「URL を聞く・落とす・描く」の3つ。URL は端末に残して先に出し、画像は CachedNetworkImage でディスクに残し、写真は上げる前に縮める

    • cacheKey を渡すと URL は見られなくなる。キーが同じなら、期限(Cache-Control、なければ7日)まで前の写真が出る

    • キーには「写真を上げたら必ず変わる値」を入れる。パスの変化で見ると、同じパスへの上書きで外れる

    • テストは、アプリと同じパスの使い方で書く

    同じパスに上書きする作りで、写真の差し替えをどう見分けているか、コメントで教えてください。


    この記事の実例にしたアプリです。

    「おもちゃ帳」は、子どもの持ち物・欲しいもの・服やおむつのサイズを、写真で家族みんなと共有するアプリです。

    • App Store:https://apps.apple.com/jp/app/id6809164293

    • Google Play:https://play.google.com/store/apps/details?id=com.basewakamaru.omocha_cho

     
     
    個人でスマホアプリを作っています。草野球の打撃成績アプリ「.333」や、子どものおもちゃを記録する「おもちゃ帳」など、自分や家族が欲しかったものばかり。AIのClaudeと一緒に開発していて、うまくいった話も審査で落ちた話も、ゆるく書いていきます。

    あなたへのおすすめ