🎼

Flutter用データベースIsarとRiverpodを組み合わせて使う

2023/03/24に公開

個人的に作りたいアプリがあるので、Flutterで開発をしています。スタンドアロンで動かしたかったので、ローカルで動くデータベースをいくつか検討し、最終的にIsarというやつを使いはじめました。

https://isar.dev/ja/

NoSQLでスキーマレスなのでわずらわしい設定みたいなものはないし、条件を指定して絞り込みをしつつ変更があるかを監視できるし、なかなか良いです。なによりモデルのクラスがシンプルに記述できるのが気に入ってます。

ちなみにデータベースの比較にはこの辺を参考にしました。ObjectBoxも気になってます。

https://kabochapo.hateblo.jp/entry/2020/02/01/144411
https://blog.logrocket.com/comparing-hive-other-flutter-app-database-options/

さて、Flutterでアプリを作るならもちろんRiverpodは使いますよねと。

ということで、IsarとRiverpodを組み合わせるのってどんな感じかサンプルのプロジェクトを作ってみました。riverpod_generatorも使っています。

https://github.com/ktakayama/IsarRiverpodSample

以下簡単にコードの解説をします。

モデルクラス

適当な値が保存できるMemoとかいうモデルを作っています。

lib/models/memo.dart

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として実装します。これでどこからでも呼べるようになって最高です。

lib/providers/isar_provider.dart
(keepAlive: true)
Future<Isar> isar(IsarRef ref) async {
  return Isar.open([MemoSchema]);
}

riverpod_generator使ってるのでわかりにくいかもですが、これでisarProviderが自動生成されます。

Memoサービスクラス

Memoの内容を読み書きするサービスクラスを実装します。ここはRiverpodあんま関係なくて、追加や変更、データの取得ができるようなシンプルなクラスです。

lib/services/memo_service.dart
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と組み合わせやすいんじゃないかと思っています。

https://isar.dev/ja/watchers.html

Memoサービスプロバイダー

さっきのMemoServiceクラスをプロバイダーとして使えるようにします。

例えば ref.watch(memoListProvider) としてメモの一覧や、ref.watch(memoDetailProvider(id))として個別のメモの状態が取得できるようにします。

lib/providers/memo_service_provider.dart
(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();
}

memoListProvidermemoDetailProviderStreamProviderですから、非同期でデータを取ってくるための処理を書く必要があります。サンプルプロジェクトでは、final memos = ref.watch(memoListProvider).value;みたいに読み込んでいて、nullチェックをしています。データに変更があるとすぐに反映されるので便利ですね!

テスト

なにげにテストを書くのが大変でした。Isarの場合はユニットテストだと事前にスタティックライブラリをダウンロードしておく必要があります。さらに、テストごとにデータを初期化したいのでそのための工夫もしています。

最終的には以下のような共通関数を用意して、Isarを使いたい場合に呼び出すようにしました。

test/test_utils.dart
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を読み合わせてあれこれしたい人の参考になれば幸いです。

GitHubで編集を提案

Discussion