ローカルキャッシュ、古いデータ、リフレッシュルールについてのFlutterキャッシュ戦略:何を保存し、いつ無効化し、画面の表示を一貫させるか。

モバイルアプリのキャッシュは、ネットワークを待たずに次の画面をすぐに描画できるようにデータのコピーをメモリや端末に置いておくことです。そのデータはリスト、ユーザープロファイル、検索結果などさまざまです。
厄介なのは、キャッシュはしばしば少し古くなる点です。ユーザーはすぐに気づきます:更新されない価格、止まって見えるバッジ、変更直後に詳細画面に古い情報が出るなど。デバッグを難しくするのはタイミングです。同じエンドポイントでも、プル・トゥ・リフレッシュ後は正しいが、戻る操作、アプリ復帰、アカウント切替後に間違って見えることがあります。
ここには現実的なトレードオフがあります。常に新しいデータを取ると画面が遅くぎくしゃくし、バッテリーと通信を無駄にします。積極的にキャッシュするとアプリは速く感じますが、表示を信頼しなくなります。
シンプルな目標が助けになります:新鮮さを予測可能にすること。各画面が何を表示していいか(新鮮、やや古い、オフライン表示)、データをどれくらい置いておくか、どのイベントで無効化するかを決めます。
典型的なフローを想像してください:ユーザーが注文を開いてから注文一覧に戻る。一覧がキャッシュから来ると古いステータスのままかもしれません。毎回リフレッシュすると画面がチカチカして遅く感じます。「キャッシュを即座に表示し、裏で更新して、応答が来たら両方の画面を更新する」といった明確なルールがあればナビゲーション全体で一貫します。
キャッシュは単なる“保存データ”ではなく、保存されたコピーとそのコピーがまだ有効かどうかのルールです。ペイロードだけを保存してルールを持たないと、画面ごとに違う現実が生まれます。
実用的なモデルとして、キャッシュ項目を次の三つの状態に分けます:
この考え方により、UIは同じ状態を見たときに同じように振る舞います。
新鮮さのルールはチームメイトに説明できる信号に基づけるべきです。一般的には時間ベースの有効期限(例:5分)、バージョン変更(スキーマやアプリのバージョン)、ユーザーアクション(プル・トゥ・リフレッシュ、投稿、削除)、サーバーからのヒント(ETag、updatedAt、明示的な"cache invalid"応答)などがあります。
例:プロフィール画面はキャッシュしたユーザーデータを即表示します。もし古いが使える場合は名前とアバターを表示して静かに更新します。ユーザーがプロフィールを編集した直後なら、それは更新が必要の瞬間です。アプリはキャッシュをすぐ更新して、すべての画面が一貫するようにします。
ルールの所有者を決めてください。ほとんどのアプリではデータ層が新鮮さと無効化を持ち、UIはそれに反応する(キャッシュを表示、ローディング、エラーを表示)だけにするとよいです。これにより各画面が独自ルールを作るのを防げます。
良いキャッシュは一つの問いから始まります:このデータが少し古くてもユーザーに害があるか? 答えが「たぶん問題ない」ならローカルキャッシュに向いています。
よく読まれて変化が遅いデータはキャッシュの価値が高い:人がよくスクロールするフィードやリスト、カタログ的なコンテンツ(商品、記事、テンプレート)、カテゴリや国のような参照データ。設定やプリファレンス、名前やアバターURLのような基本的なプロフィールもここに入ります。
リスクが高いのは金銭や時間に直結するデータです。残高、支払い状況、在庫の有無、予約スロット、配達ETA、「最終オンライン」などは古いと問題になります。速度のためにキャッシュはできても、意思決定の直前ではキャッシュを一時的なプレースホルダーと扱い、必ず最新化してください(例:注文確定の直前)。
派生したUI状態も別枠です。選択されたタブ、フィルタ、検索クエリ、並べ替え、スクロール位置などを保存するとナビゲーションが滑らかになりますが、古い選択が予期せず戻ると混乱します。簡単なルールが有効です:ユーザーがそのフローにいる間はUI状態をメモリに保ち、「最初からやり直す」(ホームに戻るなど)ときにリセットする。
セキュリティやプライバシーのリスクを作るデータはキャッシュしないでください:秘密情報(パスワード、APIキー)、ワンタイムトークン(OTP、パスワードリセットトークン)、フルカード情報などはオフラインアクセスが本当に必要でない限りキャッシュしないこと。詐欺リスクを高めるものは絶対に保存しないでください。
ショッピングアプリなら、商品リストのキャッシュは大きな利点です。一方、チェックアウト画面では合計や在庫は購入直前に必ず最新化するべきです。
ほとんどのFlutterアプリはローカルキャッシュを必要とします。画面が速くなり、ネットワーク待ちで空白になることを避けられます。重要なのはキャッシュがどこにあるかで、速度、容量、クリーンアップ挙動が変わります。
メモリキャッシュは最速です。アプリが開いている間に再利用するデータ(現在のユーザープロファイル、直近の検索結果、直前に見た商品)に最適です。欠点はアプリが終了すると消えるため、コールドスタートやオフラインでは役に立ちません。
ディスクのキー・バリューは再起動後も残したい小さな項目に向きます。設定や「最後に選んだタブ」、めったに変わらない小さなJSONレスポンスなどです。意図的に小さく保ってください。大きなリストを次々落とすと更新が面倒になり肥大化します。
ローカルデータベースはデータが大きい、構造化されている、オフライン動作が必要な場合に適しています。またクエリ(「未読メッセージ」「カート内アイテム」「先月の注文」)が必要なときにも便利です。
予測可能にするには、データタイプごとに主要な保存場所を一つ選び、同じデータを三箇所に複製するのは避けます。
簡単な目安:
サイズも計画してください。「大きすぎる」とは何か、どれくらいの期間保持するか、どうやってクリーンアップするかを決めます。例:検索結果は直近20件に上限を設け、30日より古いレコードを定期的に削除する、など。
リフレッシュルールは画面ごとに一文で説明できるくらいシンプルにします。これが賢いキャッシュが効く理由:画面は速く、かつ信用できるままです。
最も単純なのはTTL(time-to-live)です。データをタイムスタンプとともに保存し、例えば5分は新鮮と扱い、それ以降は古くなったと見なします。TTLはフィード、カテゴリ、レコメンデーションのような“あると嬉しい”データに向きます。
便利な改良はソフトTTLとハードTTLに分けることです。
ソフトTTLではキャッシュを即表示して裏で更新し、結果が変わっていればUIを更新します。ハードTTLでは期限切れの古いデータは表示しません。ローダーで待つか「オフライン/再試行」状態を出します。ハードTTLは間違っていることが遅いより悪いケース(残高、注文状況、権限)に向きます。
バックエンドが対応していれば、ETagやupdatedAt、バージョンフィールドを使って「変更があるときだけ更新」する方が望ましいです。変化がなければ全ペイロードのダウンロードを省けます。
ユーザーフレンドリーなデフォルトはstale-while-revalidateです:即表示、裏で更新し、差分があれば再描画。速さと安定の両方を得られます。
画面ごとの新鮮度の例:
間違えるコストでルールを選んでください。単に取得コストだけで決めないこと。
キャッシュ無効化は一つの問いから始まります:どのイベントが、再取得のコストを払ってでもキャッシュを信用できなくするか? トリガーを少数に絞って守れば挙動は予測可能でUIは安定します。
実アプリで重要なトリガー:
例:ユーザーがプロフィール写真を編集して戻る場合、時間ベースの更新だけに頼ると一覧に古い画像が残ることがあります。代わりに編集をトリガーとしてキャッシュのプロフィールオブジェクトを即更新し、新しいタイムスタンプで新鮮とマークします。
無効化ルールは少なく明示的に保ってください。どのイベントがキャッシュを無効にするか明確でなければ、頻繁に更新してUIが遅くなるか、逆に重要なときに更新されなくて古い表示が残ります。
まず主要な画面とそれぞれが必要とするデータを列挙します。エンドポイントではなく、ユーザーに見えるオブジェクト(プロフィール、カート、注文一覧、カタログアイテム、未読数)で考えてください。
次に、データタイプごとに一つのソース・オブ・トゥルースを選びます。Flutterでは通常リポジトリがこれに当たり、データがどこから来るか(メモリ、ディスク、ネットワーク)を隠します。画面はネットワークにいつ当たるかを決めるのではなく、リポジトリにデータを要求して返された状態に反応します。
実践的なフロー:
メタデータがルールを強制可能にします。ownerUserIdが変われば(ログアウト/ログイン)古い行を即ドロップまたは無視できます。これにより一瞬前のユーザーデータが表示されるのを防げます。
UIの振る舞いでは“古い”の意味を前もって決めます。一般的なルール:古いデータを即表示して画面が空白にならないようにし、裏で更新を始め、結果が来れば更新する。更新が失敗したら古いデータを表示したまま小さなエラーを示す。
そしていくつかの退屈なテストでルールを固定します:
これが「キャッシュがある」状態と「アプリが毎回同じ振る舞いをする」状態の差です。
詳細を編集して戻ったら古い値が残っている、というのが信頼を一番壊します。ナビゲーション間の一貫性は、すべての画面が同じソースを読むことで生まれます。
良いルールは:1回取得して1回保存し、何度でも描画する。画面が同じエンドポイントを独立して叩いてプライベートなコピーを持つのは避けます。共有ストア(状態管理層)にキャッシュを置き、一覧と詳細の両方が同じデータを監視するようにします。
現在の値とその新鮮さを所有する一つの場所を作ります。画面はリフレッシュを要求できても、それぞれタイマーやリトライやパースを管理するべきではありません。
実用的な習慣:
良いルールでも、オフラインや遅いネットワークでは古いデータが表示されます。小さく穏やかな表示でそれを示しましょう:"さっき更新"のタイムスタンプ、控えめな"更新中…"表示、"オフライン"バッジなど。
編集操作では楽観的更新が好まれることが多いです。例:詳細画面で価格を変えたら共有ストアを即更新し、戻ったときに一覧で新価格が見えるようにします。保存が失敗したらロールバックして短いエラーを表示します。
ほとんどの失敗は地味です:キャッシュは動いているが、誰もいつ使うか、いつ失効するか、誰が所有するか説明できない。
最初の落とし穴はメタデータなしのキャッシュです。ペイロードだけを保存すると古いかどうか、どのアプリバージョンが作ったか、どのユーザーに属するか分かりません。最低でもsavedAt、簡単なversion、userIdを保存してください。それだけで「なぜ画面がおかしい?」バグの多くが防げます。
別のよくある問題は同じデータに対する複数のキャッシュが所有者なしに存在することです。リスト画面がメモリにリストを持ち、リポジトリがディスクに書き、詳細画面が別で取得して別に保存する――こうなると矛盾します。データセットごとに所有者(たとえばProfileRepository)を決め、すべての画面がそこを通すようにしてください。
アカウント変更は落とし穴になりやすいです。ログアウトやアカウント切替時にユーザー領域のテーブルやキーをクリアしないと、前のユーザーのプロフィール写真や注文が一瞬表示されてしまい、プライバシー問題になります。
実践的な対策:
例:商品リストはキャッシュから即ロードして裏で更新する。更新が失敗したらキャッシュ表示は続けつつ、古い可能性があることを明示して再試行を提供します。更新にブロックしてUIを止めないでください。
出荷前にキャッシュを「なんとなく動いている」からテスト可能なルールへ変えます。ユーザーは戻る操作、オフライン、別アカウントでサインインした後でも整合するデータを見るべきです。
画面ごとにデータがどれくらい新鮮と見なせるかを決めます。動きの速いデータは分単位、遅いデータは時間単位か日単位でもよいです。新鮮でないときにどうするかも決めます:裏で更新、開いたときに更新、手動更新。
各データ型について、どのイベントでキャッシュを消すか/回避するかを決めます。共通のトリガーはログアウト、項目の編集、アカウント切替、データ形状を変えるアプリ更新などです。
キャッシュエントリにはペイロードの横に小さなメタデータを保存してください:
所有者を明確に保つ:データタイプごとに一つのリポジトリ(例:ProductsRepository)。ウィジェットはデータを要求するだけで、キャッシュルールを決めないように。
オフライン挙動も決めてテストしてください。どの画面がキャッシュを表示するか、どの操作を無効にするか、表示文言(“保存されたデータを表示中”や明確な更新コントロール)を確認します。キャッシュを使う画面には手動更新が必ずあり、見つけやすくしてください。
簡単なショップアプリを想像してください:商品カタログ(一覧)、商品詳細、Favoritesタブの3画面。ユーザーはカタログをスクロールし、商品を開き、ハートを押してお気に入りにします。目標は遅いネットワークでも速く感じ、矛盾を見せないことです。
即時描画に役立つものをローカルにキャッシュします:カタログページ(ID、タイトル、価格、サムネURL、お気に入りフラグ)、商品詳細(説明、仕様、在庫、lastUpdated)、画像メタ(URL、サイズ、キャッシュキー)、ユーザーのお気に入り(商品IDセット、タイムスタンプ付きでも可)。
カタログを開くとキャッシュ結果を即表示し、裏で再検証します。新鮮なデータが届いたら変わった部分だけ更新し、スクロール位置は維持します。
お気に入りトグルは一貫性が必須の操作と扱います。ローカルのお気に入りセットを即更新(楽観更新)し、対応するキャッシュ商品行と商品詳細を更新します。ネットワーク呼び出しが失敗したらロールバックしてメッセージを表示します。
ナビゲーションの一貫性を保つため、一覧のバッジも詳細のハートも同じソース(ローカルキャッシュ/ストア)から駆動します。戻ったときにリストのハートが即更新され、詳細は一覧からの変更を反映し、Favoritesタブのカウントも待たずに一致します。
簡単なリフレッシュルールを追加:カタログは短め(分単位)、商品詳細はやや長め、お気に入りは期限なしだがログイン/ログアウト後に再整合する。
チームが一ページのルールを指さして何が起こるか合意できれば、キャッシュは謎でなくなります。目標は完璧さではなく、リリース間で変わらない予測可能な挙動です。
画面ごとに小さな表を書いておくと良いです:画面名と主要データ、キャッシュ場所とキー、新鮮度ルール(TTL、イベントベース、手動)、無効化トリガー、更新中にユーザーに見せるもの。
チューニング中は軽いログを残しましょう。キャッシュヒット・ミス、なぜ更新が起きたか(TTL切れ、ユーザー更新、アプリ復帰、ミューテーション完了)を記録します。「このリストがおかしい」と言われたときにそのログで原因を特定できます。
まずは単純なTTLから始め、ユーザーの気づきに応じて洗練してください。ニュースフィードは5〜10分の古さを許容できるかもしれませんが、注文状況は復帰時とチェックアウト後に更新が必要です。
素早くFlutterアプリを作るなら、実装前にデータ層とキャッシュルールをアウトライン化するのが役立ちます。Koder.ai(koder.ai)を使っているチームなら、Planning Modeで画面ごとのルールを書いてからそれに合わせて実装すると効率的です。
リフレッシュ挙動を調整するときは、安定した画面を保護しながら実験してください。スナップショットとロールバックが、新しいルールでフリッカーや空状態、不整合カウントが出たときに時間を節約してくれます。
まずは画面ごとに一つの明確なルールを決めてください:何を即座に表示できるか(キャッシュ)、いつ更新が必須か、更新中にユーザーに何を見せるか。一文で説明できないルールは、将来的に一貫性を欠きます。
キャッシュには新鮮度の状態を持たせます。新鮮ならそのまま表示。古いが使えるなら今表示して裏で更新。更新が必要なら表示前に取得する(またはローディングを表示)。こうするとUIの振る舞いが毎回同じになります。
頻繁に読まれて少しくらい古くても問題にならないデータ(フィード、カタログ、参照データ、基本的なプロフィール情報)はキャッシュに向きます。残高や支払い状態、在庫、配達ETAなど時間に敏感なデータは注意が必要で、速度のためにキャッシュしても、意思決定の直前に必ず最新化してください。
現在のセッション内で素早く再利用するならメモリ。再起動後も残したい小さな設定や小さなJSONはディスクのキー・バリュー。データが大きく構造化され、クエリやオフライン動作が必要ならローカルDB。用途に応じて使い分けてください。
プレーンなTTLは良いデフォルトです:一定時間は新鮮と見なし、過ぎたら更新。多くの画面では「今はキャッシュを表示して裏で再検証し、変化があれば更新する」方式(stale-while-revalidate)のほうが、空白画面やフリッカーを避けられてユーザー体験が良くなります。
キャッシュの信頼性を損なうイベントが発生したときに無効化します:ユーザー編集(作成・更新・削除)、ログイン/ログアウト/アカウント切替、バックグラウンドからの復帰(TTLより古ければ更新)、明示的なユーザー更新など。トリガーは少なく明確にするのが肝心です。
リストと詳細で同じデータ源(ソース・オブ・トゥルース)を参照すること。詳細画面で編集したら共有キャッシュをすぐ更新しておき、戻ったときにリストが古い値を表示しないようにします。
ペイロードの横に小さなメタデータ(保存時間とユーザーIDなど)を必ず保存し、ログアウトやアカウント切替でユーザー領域のキャッシュをすぐにクリアまたは隔離してください。また古いユーザーに紐づくリクエストはキャンセルすると一瞬前のユーザーデータが表示されるのを防げます。
基本は古いデータを残したままにしておき、目立たない小さなエラー表示と再試行を提供します。もし画面上で古いデータを見せられないなら、must-refreshルールにしてローディングやオフライン表示に切り替えてください。
キャッシュルールや新鮮度判定はデータ層(リポジトリなど)に置いて、ウィジェットはその状態を受け取って反応するだけにします。Koder.aiを使うなら、まずPlanning Modeで画面ごとのルールを書いてから実装すると一貫性が保ちやすいです。