🛁

【Flutter】サウナライフでユーザー体験を良くするために工夫したFirestoreキャッシュの仕組み

2024/11/27に公開

マップで近くのサウナを簡単に検索できるアプリ「サウナライフ」の開発・運用しています。
ユーザー体験を良くするために工夫したキャッシュの仕組みについて解説します。

サウナライフのダウンロードはこちらから👇
iOS版, Android版

X(旧:Twitter)やGithubアプリなど、データの初回読み込みは少し時間がかかりますが、一度読み込でおくとアプリをキルしてもすぐにデータが反映されます。アプリ内部のログまで辿っていませんが、挙動的にキャッシュを活用しているかと思います。

同じようなことを、自社アプリでもできないかと思い、キャッシュを活用したデータフェッチ処理を実現しました。

サウナライフのデータベースはFirestoreを使っています。Firestoreにはオフラインサポートされており、キャッシュを簡単に利用できます。

Firestoreのキャッシュ

Firestoreにはオフラインデータの永続性をサポートしており、キャッシュ機能を利用できます。[1]

  • 定期的に古いキャッシュを削除
  • キャッシュサイズ変更可能

Firestoreのデータ取得には大きく分けてスナップショットリスナードキュメント/コレクションパスから取得する方法の2種類あります。

スナップショットリスナーは、データをリアルタイムに取得できます。既にデータ取得済みの場合、キャッシュからデータが取得され、その後サーバーからデータが取得されます(2回発火する)

スナップショットリスナー
FirebaseFirestore.instance.doc('feeds/1').snapshots().listen((snap) {
    // キャッシュにデータがあると、2回発火される
    final data = snap.data();
    debugPrint('data: $data, isFromCache: ${snap.metadata.isFromCache}'); 
});
スナップショットリスナー(キャッシュのみ)
// sourceをListenSource.cacheにするとキャッシュのみ取得
FirebaseFirestore.instance.doc('feeds/1').snapshots(
    source: ListenSource.cache,
).listen((snap) {
    final data = snap.data();
    debugPrint('data: $data, isFromCache: ${snap.metadata.isFromCache}');
});

ドキュメント/コレクションパスは、データソースの参照先(Source)を変更することでキャッシュから取得できます。デフォルトはserverAndCacheになっており、オンラインの場合はサーバーから取得し、オフラインの場合はキャッシュから取得する挙動です。

Source.cacheを指定すると、ローカルキャッシュから取得します。

ドキュメントから取得
final doc = await FirebaseFirestore.instance
    .doc('feeds/1')
    .get(const GetOptions(source: Source.cache)); // キャッシュ指定
final data = doc.data();
debugPrint('data: $data, isFromCache: ${doc.metadata.isFromCache}');
コレクションから取得
final snap = await FirebaseFirestore.instance
    .collection('feeds')
    .limit(20)
    .get(const GetOptions(source: Source.cache)); // キャッシュ指定
for (final doc in snap.docs) {
  final data = doc.data();
  debugPrint('data: $data, isFromCache: ${doc.metadata.isFromCache}');
}

Firestoreのキャッシュを考慮した実装

こちらの実現したい挙動としては

  • キャッシュが無い場合
    • サーバーからデータを取得して反映
  • キャッシュが有る場合(2回Buildを走らせる)
    • キャッシュからデータを取得して反映
    • その間にサーバーからデータを取得して再度反映

スナップショットリスナーはこの挙動を実現していますが、リアルタイム反映をせずに実現したかったので以下のようなラッパーを作りました。

ドキュメントを取得する
Future<Document<T>> fetchDoc<T extends Object>(
    String documentPath, {
    Source source = Source.serverAndCache,
    void Function(Document<T>?)? fromCache,
    required T Function(Map<String, dynamic>) decode,
}) async {
    final firestore = FirebaseFirestore.instance;

    // キャッシュから取得したデータをfromCacheで返却
    if (fromCache != null) {
      try {
        final cache = await firestore
            .doc(documentPath)
            .get(const GetOptions(source: Source.cache));
        fromCache(
          Document(
            ref: cache.reference,
            exists: cache.exists,
            entity: cache.exists ? decode(cache.data()!) : null,
          ),
        );
      } on Exception catch (_) {
        // ignore exception
        fromCache(null);
      }
    }

    // サーバーから取得したデータを返却
    final snap =
        await firestore.doc(documentPath).get(GetOptions(source: source));
    
    return Document(
      ref: snap.reference,
      exists: snap.exists,
      entity: snap.exists ? decode(snap.data()!) : null,
    );
}
コレクションからドキュメントリストを取得する
Future<List<Document<T>>> fetchDocs<T extends Object>(
    String collectionPath, {
    Source source = Source.serverAndCache,
    void Function(List<Document<T>>?)? fromCache,
    required T Function(Map<String, dynamic>) decode,
}) async {
    // 好みのクエリを指定
    final query =
        FirebaseFirestore.instance.collection(collectionPath).limit(20);

    // キャッシュから取得したデータをfromCacheで返却
    if (fromCache != null) {
      try {
        final cache = await query.get(const GetOptions(source: Source.cache));
        fromCache(
          cache.docs
              .map(
                (doc) => Document(
                  ref: doc.reference,
                  exists: doc.exists,
                  entity: doc.exists ? decode(doc.data()) : null,
                ),
              )
              .toList(),
        );
      } on Exception catch (_) {
        // ignore exception
        fromCache(null);
      }
    }
    
    // サーバーから取得したデータを返却
    final snap = await query.get(GetOptions(source: source));
    return snap.docs
        .map(
          (doc) => Document(
            ref: doc.reference,
            exists: doc.exists,
            entity: doc.exists ? decode(doc.data()) : null,
          ),
        )
        .toList();
}
Documentクラス
typedef DataType = Map<String, dynamic>;

class Document<T extends Object> extends Equatable {
  const Document({
    required this.ref,
    required this.exists,
    required this.entity,
  });

  final DocumentReference<DataType> ref;
  final bool exists;
  final T? entity;

  String get id => ref.id;
  String get collectionName => ref.parent.id;
  String get path => ref.path;

  Document<T> copyWith(T newEntity) => Document(
        ref: ref,
        exists: exists,
        entity: newEntity,
      );

  
  List<Object?> get props => [ref, exists, entity];
}

注意として、ドキュメント/コレクションパスからキャッシュを取得した際に、キャッシュがなければ、[cloud_firestore/unavailable] The service is currently unavailable. This is a most likely a transient condition and may be corrected by retrying with a backoff.が発生します。そのためキャッシュから取得する処理をtry - catchでエラーを打ち消しています。

これらのラッパーを利用して、Widgetに反映しています。

サウナライフのアプリを触ってみてください、読み込みで待たされることは少なく、快適にアプリを利用できるかと思います。

参考

キャッシュを使ってアプリをいい感じにする@2023.12.19(火) 【ハイブリット開催】Mobile勉強会 Wantedly × チームラボ × Sansan #12

脚注
  1. オフラインでデータにアクセスする ↩︎

株式会社Never

Discussion