Flutter用データベースIsarとRiverpodを組み合わせて使う
個人的に作りたいアプリがあるので、Flutterで開発をしています。スタンドアロンで動かしたかったので、ローカルで動くデータベースをいくつか検討し、最終的にIsarというやつを使いはじめました。
NoSQLでスキーマレスなのでわずらわしい設定みたいなものはないし、条件を指定して絞り込みをしつつ変更があるかを監視できるし、なかなか良いです。なによりモデルのクラスがシンプルに記述できるのが気に入ってます。
ちなみにデータベースの比較にはこの辺を参考にしました。ObjectBoxも気になってます。
さて、Flutterでアプリを作るならもちろんRiverpodは使いますよねと。
ということで、IsarとRiverpodを組み合わせるのってどんな感じかサンプルのプロジェクトを作ってみました。riverpod_generatorも使っています。
以下簡単にコードの解説をします。
モデルクラス
適当な値が保存できるMemo
とかいうモデルを作っています。
class Memo {
Memo({
required this.title,
required this.body,
required this.updated,
}) : id = Isar.autoIncrement,
created = DateTime.now();
Id id;
final String title;
final String body;
final DateTime created;
final DateTime updated;
String fullTitle() {
return 'ID: $id: title';
}
}
Isarオブジェクトのプロバイダー
Isarの初期化はisarProvider
として実装します。これでどこからでも呼べるようになって最高です。
(keepAlive: true)
Future<Isar> isar(IsarRef ref) async {
return Isar.open([MemoSchema]);
}
riverpod_generator使ってるのでわかりにくいかもですが、これでisarProvider
が自動生成されます。
Memoサービスクラス
Memo
の内容を読み書きするサービスクラスを実装します。ここはRiverpodあんま関係なくて、追加や変更、データの取得ができるようなシンプルなクラスです。
class MemoService {
const MemoService(this.isar);
final Isar isar;
// 指定したIDのメモを返す
Stream<Memo> watchMemo(Id id) async* {
final query = isar.memos.where().idEqualTo(id);
await for (final results in query.watch(fireImmediately: true)) {
if (results.isNotEmpty) {
yield results.first;
}
}
}
// すべてのメモを返す
Stream<List<Memo>> watchAllMemos() async* {
final query = isar.memos.where().sortByUpdated().build();
await for (final results in query.watch(fireImmediately: true)) {
if (results.isNotEmpty) {
yield results;
} else {
yield [];
}
}
}
// メモを追加
Future<Memo> addMemo() async {
final randomString = Random().nextInt(10000).toString();
final title = 'title$randomString';
final memo = Memo(title: title, body: 'body', updated: DateTime.now());
await isar.writeTxn(() async {
await isar.memos.put(memo);
});
return memo;
}
// 指定したIDのメモを削除
removeMemo(int id) async {
await isar.writeTxn(() async {
await isar.memos.delete(id);
});
}
}
以上のようにwatchメソッドを使うと、オブジェクトの変更が監視できます。Riverpodと組み合わせやすいんじゃないかと思っています。
Memoサービスプロバイダー
さっきのMemoService
クラスをプロバイダーとして使えるようにします。
例えば ref.watch(memoListProvider)
としてメモの一覧や、ref.watch(memoDetailProvider(id))
として個別のメモの状態が取得できるようにします。
(keepAlive: true)
Future<MemoService> memoService(MemoServiceRef ref) async {
final isar = await ref.watch(isarProvider.future);
return MemoService(isar);
}
Stream<Memo> memoDetail(MemoDetailRef ref, Id id) async* {
final service = await ref.watch(memoServiceProvider.future);
yield* service.watchMemo(id);
}
Stream<List<Memo>> memoList(MemoListRef ref) async* {
final service = await ref.watch(memoServiceProvider.future);
yield* service.watchAllMemos();
}
memoListProvider
もmemoDetailProvider
もStreamProvider
ですから、非同期でデータを取ってくるための処理を書く必要があります。サンプルプロジェクトでは、final memos = ref.watch(memoListProvider).value;
みたいに読み込んでいて、nullチェックをしています。データに変更があるとすぐに反映されるので便利ですね!
テスト
なにげにテストを書くのが大変でした。Isarの場合はユニットテストだと事前にスタティックライブラリをダウンロードしておく必要があります。さらに、テストごとにデータを初期化したいのでそのための工夫もしています。
最終的には以下のような共通関数を用意して、Isarを使いたい場合に呼び出すようにしました。
Future<ProviderContainer> createContainer(
{List<Override> overrides = const []}) async {
// スタティックライブラリのダウンロード
final evacuation = HttpOverrides.current;
HttpOverrides.global = null;
await Isar.initializeIsarCore(download: true);
HttpOverrides.global = evacuation;
// Isarオブジェクトの用意
final random = Random().nextInt(10000).toString();
final isar = await Isar.open(
[MemoSchema],
name: 'test_${random}_tmp',
);
// プロバイダーのoverride
final container = ProviderContainer(
overrides: overrides + [isarProvider.overrideWith((ref) => isar)]);
// 終了処理
addTearDown(() async {
await isar.close(deleteFromDisk: true);
container.dispose();
});
return container;
}
IsarとRiverpodを読み合わせてあれこれしたい人の参考になれば幸いです。
Discussion