[FlutterFlow] Hiveを使ったキャッシュ機能の作成

# はじめに
:::note info
この記事では、FlutterFlowをベースに作成したアプリをローカルに落として作業しており、ある程度Flutterで開発可能な読者を想定しています。
:::
この記事でできるようになること
- HiveによってFirebaseのドキュメントを格納する
- Firebaseのドキュメントがアップデートされた場合、それをリアルタイムリスナーによって反映する


# FlutterFlowについて

FlutterFlowは、爆速モバイルアプリ開発を実現する、画期的なノーコードツールです。簡単なアプリなら本当にその日のうちにテストフライトまでが完了していると優れたツールです。
他のノーコードツールと違って書ける、Flutterアプリとして吐き出せる、という点が強みです。

https://flutterflow.io/

最近またアップデートが入り使いやすくなったとのことで、ますます期待できますね。

# 問題点
このように、非常に便利なFlutterFlowですが、まだまだ改善の余地がある機能もいくつかあります。
その一つが今回取り上げた **キャッシュ機能** です。
FlutterFlowにもキャッシュ機能は備わっていますが、
- アプリを落とすと削除されてしまう
- Firebaseのレコード10個までしか保存できない

といった具合で、正直実用に足る機能ではありませんでした。
とは言え、毎回クエリするたびにローディングが長い...。


# 解決策
そこで今回`Hive`を使ってキャッシュ機能を作ってみることにしました。

https://github.com/isar/hive

Hiveは次のような特徴を持ったパッケージです。
- Dart向けの軽量なローカルデータベース
- データを端末内にキーバリューの形で保存することが可能
- アプリを落としてもデータが消えることはない
- 携帯の容量が許す限りいくらでもキャッシュできる（でもしないほうがいい）

ということで、先ほどの問題をまるっと解決してくれること間違いありません。

では、さっそく実装してみたいと思います。

# Hiveでのキャッシュ機能の実装

FlutterFlowのプロジェクトを作成し、Firebaseとの連携・テーブルの追加を済ませておいてください。
こんな感じで作ってみました。デフォルトに`is_admin`というフラグを追加しただけです。

![sample project - FlutterFlow 2024-09-28 20-44-24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628974/a0c3e97f-ea75-1745-6f48-33d10e1fc6a7.png)

これをローカルに落とします。
ここで設定したuserテーブルのスキーマは、`lib/backend/schema/user_record.dart`で定義されています。
このコードに書き加えていきましょう。

## Hiveで使用するBoxについて

Hiveでは変数などを格納するためにBoxというオブジェクトを使用します。

今回、このBoxには

- キー：`Reference`をStringにキャストしたもの
- バリュー：`Document`のそれぞれの要素を`Map<String, dynamic>`にキャストしたもの

として保存します。

依存関係を追加しておきます。
```yaml:pubspec.yaml
  hive: ^2.0.5  # Hiveの依存関係
  hive_flutter: ^1.1.0  # FlutterとHiveの連携
```

## Hiveに保存・取り出すための処理

Hiveは`DocumentReference`や`Document`そのものを格納することはできません。

Mapとして格納する際には`DocumentReference`型の変数は、一度`String`型の変数にキャストします。再び取り出す際に、これらの`String`で表されるPathを`DocumentReference`型に戻して渡します。

まずはそのためのコードを作成します。

### 保存用の処理

上述のように、Hiveパッケージを使用してBoxに保存する際は、`DocumentReference`型の変数から`String`にキャストする必要があります。また`DateTime`型の変数も、`String`型にキャストしています。
`UserRecord`クラスのメソッドとして、`toMap()`関数を追加します。

```dart:user_record.dart
class UserRecord extends FirestoreRecord {
...

  Map<String, dynamic> toMap() {
    return {
      'email': _email ?? "",
      'display_name': _displayName ?? "",
      'photo_url': _photoUrl ?? "",
      'uid': _uid ?? "",
      'created_time': _createdTime?.toIso8601String(),
      'phone_number': _phoneNumber ?? "",
      'is_logged_in': _isAdmin ?? false,
    };
  }
}
```

### 取り出し用の処理

今度は、保存された`Map<String, dynamic>`の中からフィールドを取り出し、`UserRecord`型の変数として取得する関数を作ります。
`UserRecord`クラスのメソッドとして、`fromMap()`関数を追加します。（`toMap()`の下くらいに）
ここでは、引数としてHiveで保存したキーバリューを渡して全てのフィールドの要素を取り出し、`UserRecord`型として返します。

```dart:user_record.dart
class UserRecord extends FirestoreRecord {
...

  Map<String, dynamic> toMap() {
    ...
  }
  
  static UserRecord fromMap(
      DocumentReference reference, Map<String, dynamic> data) {
    return UserRecord._(reference, {
      'email': data['email'] ?? "",
      'display_name': data['display_name'] ?? "",
      'photo_url': data['photo_url'] ?? "",
      'uid': data['uid'] ?? "",
      'created_time': data['created_time'] != null
          ? DateTime.tryParse(data['created_time'] as String)
          : DateTime.now(),
      'phone_number': data['phone_number'] ?? "",
      'is_logged_in': data['is_logged_in'] ?? false,
    });
  }

}
```

## リアルタイムリスナーの実装
Firebaseのドキュメントアップデート時にリアルタイムリスナーで反映させるために、`ChangeNotifier`を使用します。

ちなみに、ChangeNotifierについても軽く説明です。

> `ChangeNotifier` は Flutter で状態管理を行うためのクラスで、他のウィジェットに状態の変更を通知する仕組みを提供します。主に `Provider` パッケージと組み合わせて使用され、状態の変更が発生したときに UI の更新をトリガーする役割を持ちます。
> 
> 
> `ChangeNotifier` の主なメソッド
> 
> - `addListener(VoidCallback listener)`: 状態の変更をリッスンするリスナーを追加します。
> - `removeListener(VoidCallback listener)`: 登録済みのリスナーを削除します。
> - `notifyListeners()`: 状態が変更されたことを全てのリスナーに通知します。このメソッドを呼び出すと、登録されている全てのリスナーが実行され、UI が再描画されます。
> - `dispose()`: リソースを解放する際に呼び出されるメソッド。`ChangeNotifier` を使うクラスが不要になったとき、これを呼んでリスナーを解除します。


### ベースクラスの実装

`ChangeNotifier`は先ほど述べた通り、変更を感知してサブスクライブしているウィジェットに対してその変更を通知することができるクラスです。

ここでFirebaseのリアルタイムリスナーを起動しておき、変更された場合にBoxのデータを上書きして通知を送信します。

では、`ChangeNotifier`を使用した、ベースとなるクラスを作成しましょう。
新たに`lib/backend/schema/util/hive_handler.dart`というファイルを作成して、以下のように実装します。
```dart:hive_handler.dart
import 'dart:async';
import '/backend/backend.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

abstract class BaseHiveHandler<T extends FirestoreRecord> with ChangeNotifier {
  Box<dynamic>? box;
  List<T> cachedItems = [];
  List<StreamSubscription> firebaseSubscriptionList = [];

  Future<void> initialize(String boxName) async {
    box = await Hive.openBox(boxName);

    if (box!.isNotEmpty) {
      cachedItems = box!.keys.map((path) {
        final mapData = Map<String, dynamic>.from(box!.get(path));
        return fromMap(
            FirebaseFirestore.instance.doc(mapData['reference']), mapData);
      }).toList();
    } 
    setUpFirebaseListener();
  }

  Future<T?> fetchDocumentAndSave(DocumentReference reference) async {
    try {
      if (!box!.containsKey(reference.path)) {
        final snapshot = await reference.get();

        // Firebase から取得したデータを Hive に保存し、キャッシュを更新
        if (snapshot.exists) {
          final document = fromSnapshot(snapshot);
          await saveDocumentToHive(document);
          cachedItems.add(document);

          return document;
        } else {
          return null;
        }
      } else {
        // キャッシュにデータが存在する場合はキャッシュされたデータを返す
        final cachedData = box!.get(reference.path);
        final document =
            fromMap(reference, Map<String, dynamic>.from(cachedData));
        return document;
      }
    } catch (e) {
      return null;
    }
  }

  /// ドキュメントを Hive に保存する処理
  Future<void> saveDocumentToHive(T document) async {
    try {
      final docMap = toMap(document);
      await box!.put(document.reference.path, docMap);
    } catch (e) {
      print('Error saving document to Hive: $e');
    }
  }

  void removeAllListeners() {
    for (var subscription in firebaseSubscriptionList) {
      subscription.cancel();
    }
    firebaseSubscriptionList.clear();
  }

  void setUpFirebaseListener();

  /// 抽象メソッド
  /// `fromMap`,`toMap`メソッドを抽象メソッドとして定義し、各サブクラスで実装
  T fromMap(DocumentReference reference, Map<String, dynamic> data);
  Map<String, dynamic> toMap(T item);

  /// Firebase の `DocumentSnapshot` から `T` 型のオブジェクトを作成する処理
  T fromSnapshot(DocumentSnapshot snapshot);

  /// サブスクリプションの解除とクリーンアップ
  @override
  void dispose() {
    removeAllListeners();
    super.dispose();
  }
}

```

簡単に関数について説明します。

- `initialize(String boxName)`
`Hive`を初期化し、指定されたボックス名でデータを読み込みます。データがない場合は、Firebaseから初期データを取得してキャッシュを作成し、リアルタイム更新を監視するリスナーを設定します。

- `fetchDocumentAndSave(DocumentReference reference)`
Firebaseからドキュメントを取得してキャッシュし、`cachedItems`リストに追加します。キャッシュが存在する場合はキャッシュからデータを取得します。

- `saveDocumentToHive(T document)`
指定されたドキュメントを`Hive`に保存するメソッドです。

- `removeAllListeners()`
Firebaseリスナーを解除するメソッドです。

- `setUpFirebaseListener()`
Firebaseのリアルタイムリスナーを設定するための抽象メソッドです。

- `fromMap(DocumentReference reference, Map<String, dynamic> data)`
マップ形式のデータを元に、指定された型のオブジェクトを生成する抽象メソッドです。

- `toMap(T item)`
オブジェクトをマップ形式に変換する抽象メソッドです。

- `fromSnapshot(DocumentSnapshot snapshot)`
Firebaseのスナップショットを指定された型のオブジェクトに変換する抽象メソッドです。

- `dispose()`
Firebaseリスナーを解除し、オブジェクトを破棄する処理を行うメソッドです。

### BaseHiveHandlerのサブクラスを実装

次に、先ほど実装したクラスを継承したサブクラスを実装します。
先ほどの`user_record.dart`に戻り、一番下にこのように追加します。

```dart:user_record.dart

import '/backend/schema/util/hive_handler.dart';   /// 最初にインポートも追加

...

class UserProvider extends BaseHiveHandler<UserRecord> {
  @override
  void setUpFirebaseListener() {
    print('Setting up Firebase listener for each Hive key');

    if (box == null) {
      return;
    }

    // Hive のすべてのキーを取得（各キーは Firebase の DocumentReference のパス）
    final List<String> hiveKeys = box!.keys.cast<String>().toList();

    // 各キーに対応する Firebase のリスナーを設定
    for (String key in hiveKeys) {
      final docRef = FirebaseFirestore.instance.doc(key);
      final subscription = docRef.snapshots().listen(
        (snapshot) {
          if (snapshot.exists) {
            // 変更があったドキュメントを Hive に保存
            final updatedRecord = fromSnapshot(snapshot);
            saveDocumentToHive(updatedRecord);

            // キャッシュデータも更新する
            final index = cachedItems.indexWhere(
                (item) => item.reference.path == snapshot.reference.path);
            if (index != -1) {
              cachedItems[index] = updatedRecord;
            } else {
              cachedItems.add(updatedRecord);
            }

            // UI の更新を通知
            notifyListeners();
          } 
        },
        onError: (error) =>
            print('Error listening to document ${docRef.path}: $error'),
      );

      firebaseSubscriptionList.add(subscription);
    }
  }

  @override
  UserRecord fromMap(
      DocumentReference<Object?> reference, Map<String, dynamic> data) {
    return UserRecord.fromMap(reference, data);
  }

  @override
  Map<String, dynamic> toMap(UserRecord item) {
    return {
      'email': item._email ?? "",
      'display_name': item._displayName ?? "",
      'photo_url': item._photoUrl ?? "",
      'uid': item._uid ?? "",
      'created_time': item._createdTime?.toIso8601String(),
      'phone_number': item._phoneNumber ?? "",
      'is_admin': item._isAdmin ?? false,
    };
  }

  @override
  UserRecord fromSnapshot(DocumentSnapshot<Object?> snapshot) {
    return UserRecord.fromSnapshot(snapshot);
  }
}

```
これでFirebaseが変更された際に、その変更内容をHiveに反映させ、サブスクライブしているウィジェットに通知を送信します。

**クラスの概要**
`UserProvider` は `BaseHiveHandler<UserRecord>` を継承しており、`UserRecord` 型のデータを管理するプロバイダークラスです。このクラスでは、Firebase と Hive のデータを同期し、リアルタイム更新をリスナーを通して反映する機能を提供しています。UI の更新もサポートしており、Firebase で変更があったときに自動的に通知して画面を再描画することができます。

**各メソッドの役割**
- `setUpFirebaseListener()`
Hive の全てのキー（Firebase ドキュメントのパス）に対応する Firebase リスナーを設定し、各ドキュメントの変更を監視します。
変更があった場合、そのドキュメントを Hive に保存し、キャッシュ (`cachedItems`) を更新します。
データが更新されると `notifyListeners()` を呼び出して、UI の更新を行います。

- `fromMap(DocumentReference reference, Map<String, dynamic> data)`
マップ形式 (`Map<String, dynamic>`) のデータを `UserRecord` オブジェクトに変換します。
引数の `reference` は、どの Firebase ドキュメントに対応するかを示しています。

- `toMap(UserRecord item)`
`UserRecord` オブジェクトをマップ形式 (`Map<String, dynamic>`) に変換します。
`Hive` に保存する際に、オブジェクトのプロパティをマップ形式で扱います。

- `fromSnapshot(DocumentSnapshot snapshot)`
Firebase の `DocumentSnapshot` から `UserRecord` オブジェクトを作成します。
Firebase から取得したスナップショットを `UserRecord` 型に変換して、アプリ内で扱いやすくします。

## main.dartでの呼び出し

作成した`ChangeNotifier`は`main.dart`でアプリに登録します。

```dart
import 'package:hive_flutter/hive_flutter.dart';   /// 追加
import '/backend/schema/user_record.dart';         /// 追加

void main() async {
  ...
  
  await Hive.initFlutter(); /// 追加
  
  ...
  
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => appState),
        ChangeNotifierProvider(
            create: (context) => UserProvider()..initialize()),
      ],
      child: const MyApp(),
    ),
  );
}
```

複数ある場合は上記のように`MultiProvider`で設定します。

## 使用するウィジェットでの設定方法

使用するウィジェットではこのような形で実装します。

```dart
@override
  Widget build(BuildContext context) {
    super.build(context);
    context.watch<FFAppState>();
    final userProvider = Provider.of<UserProvider>(context);

...

                itemCount: userProvider.cachedItems.length,
                itemBuilder: (context, index) {
                  final listViewUsersRecord = userProvider.cachedItems[index];
```

上記では、ウィジェットの変数として、Providerに設定したuserProviderを定義しています。

```dart
    final userProvider = Provider.of<UserProvider>(context);
```

あとはUserProviderクラスの変数として定義したcachedItemsなどを好きなところで呼んで使ってみてください。

# 結果
テスト用の弱々アンドロイドで実験しました。
10個のUserレコードを取得する場合のパフォーマンスです。

導入前：268.5*4=1071ms
![導入前.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628974/cff9b50a-008e-c3ff-a62c-d7b1e3076e28.png)

導入後：180.5*2=361ms
![導入後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628974/adb15d3c-4986-d99e-87eb-518d4372178c.png)

実際の見た目も全然違います。
ようやくローディングインジケーターから解放されました。。

# 最後に
FlutterFlow、最近もどんどんアップデートされてきていますね。
これからも使い続けたいです。