はじめに
この記事では、FlutterFlowをベースに作成したアプリをローカルに落として作業しており、ある程度Flutterで開発可能な読者を想定しています。
この記事でできるようになること
- HiveによってFirebaseのドキュメントを格納する
- Firebaseのドキュメントがアップデートされた場合、それをリアルタイムリスナーによって反映する
FlutterFlowについて
FlutterFlowは、爆速モバイルアプリ開発を実現する、画期的なノーコードツールです。簡単なアプリなら本当にその日のうちにテストフライトまでが完了していると優れたツールです。
他のノーコードツールと違って書ける、Flutterアプリとして吐き出せる、という点が強みです。
最近またアップデートが入り使いやすくなったとのことで、ますます期待できますね。
問題点
このように、非常に便利なFlutterFlowですが、まだまだ改善の余地がある機能もいくつかあります。
その一つが今回取り上げた キャッシュ機能 です。
FlutterFlowにもキャッシュ機能は備わっていますが、
- アプリを落とすと削除されてしまう
- Firebaseのレコード10個までしか保存できない
といった具合で、正直実用に足る機能ではありませんでした。
とは言え、毎回クエリするたびにローディングが長い...。
解決策
そこで今回Hive
を使ってキャッシュ機能を作ってみることにしました。
Hiveは次のような特徴を持ったパッケージです。
- Dart向けの軽量なローカルデータベース
- データを端末内にキーバリューの形で保存することが可能
- アプリを落としてもデータが消えることはない
- 携帯の容量が許す限りいくらでもキャッシュできる(でもしないほうがいい)
ということで、先ほどの問題をまるっと解決してくれること間違いありません。
では、さっそく実装してみたいと思います。
Hiveでのキャッシュ機能の実装
FlutterFlowのプロジェクトを作成し、Firebaseとの連携・テーブルの追加を済ませておいてください。
こんな感じで作ってみました。デフォルトにis_admin
というフラグを追加しただけです。
これをローカルに落とします。
ここで設定したuserテーブルのスキーマは、lib/backend/schema/user_record.dart
で定義されています。
このコードに書き加えていきましょう。
Hiveで使用するBoxについて
Hiveでは変数などを格納するためにBoxというオブジェクトを使用します。
今回、このBoxには
- キー:
Reference
をStringにキャストしたもの - バリュー:
Document
のそれぞれの要素をMap<String, dynamic>
にキャストしたもの
として保存します。
依存関係を追加しておきます。
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()
関数を追加します。
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
型として返します。
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
というファイルを作成して、以下のように実装します。
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
に戻り、一番下にこのように追加します。
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
でアプリに登録します。
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
で設定します。
使用するウィジェットでの設定方法
使用するウィジェットではこのような形で実装します。
@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を定義しています。
final userProvider = Provider.of<UserProvider>(context);
あとはUserProviderクラスの変数として定義したcachedItemsなどを好きなところで呼んで使ってみてください。
結果
テスト用の弱々アンドロイドで実験しました。
10個のUserレコードを取得する場合のパフォーマンスです。
実際の見た目も全然違います。
ようやくローディングインジケーターから解放されました。。
最後に
FlutterFlow、最近もどんどんアップデートされてきていますね。
これからも使い続けたいです。
XでもFlutterFlowに関する投稿をたまにあげてます。見ていただけると泣いて喜びます。