🍖

【Flutter】ローカル DB パッケージの Isar Database の使い方

2022/06/12に公開

はじめに

いろいろローカル DB パッケージを比較検討したのですが、 Isar Database が一番しっくりきました!各ローカル DB パッケージの簡単な比較と、簡単なメモアプリを題材にして Isar Database の具体的な使い方を紹介します!参考になれば幸いです!

今回作成したメモアプリのコードは下記で公開しています!

https://github.com/susatthi/flutter-sample-isar

変更履歴

変更履歴

2022/06/12 初版

2022/09/19 パッケージのバージョンを 3.0.0-dev.0 から 3.0.0 にアップデートし、内容も追従

ローカル DB パッケージの比較

Flutter のローカル DB のパッケージはたくさんあって悩みますよね。実際にいくつかのローカル DB パッケージを使って比較したのですが、次のサイトがとても参考になりました!

https://kabochapo.hateblo.jp/entry/2020/02/01/144411

各ローカル DB パッケージを検討してみた自分の感想です!ほぼ主観(感想)なので参考程度に。。。

Isar Database

https://pub.dev/packages/isar

  • すべてのプラットフォームをサポートしてる
  • 非同期/同期両方サポートしている
  • 全部メソッドチェーンでつなげて書けるからクエリが書きやすい(個人的な感想)
クエリの例
final collections = await isar.queryHistoryCollections
        .filter()
        .queryStringStartsWith(queryString, caseSensitive: false)
        .sortBySearchedAtDesc()
        .distinctByQueryString()
        .limit(20)
        .findAll();

ObjectBox

https://pub.dev/packages/objectbox

  • Web 版が非対応
  • DISTINCT すると ORDER BY が効かなくなった(やり方が間違ったかも or バグかも)
  • クエリが書きづらい(個人的な感想)
クエリの例
final qBuilder = _box.query(
  QueryHistoryEntity_.queryString.startsWith(queryString.toLowerCase()),
)..order(QueryHistoryEntity_.searchedAt, flags: Order.descending);
final q = qBuilder.build();
final qp = q.property(QueryHistoryEntity_.queryString)..distinct = true;
final results = qp.find().sublistSafety(0, 20);
q.close();

Drift

https://pub.dev/packages/drift/score

  • データベースを意識するので RDB に慣れてる人はよさそう
  • DISTINCT が非サポートで、代わりに GROUP BY 使おうとしたけど複雑でわかりづらかった。。。
  • クエリが書きづらい(個人的な感想)

それでは Isar Database の使い方を紹介していきます!

Isar Database の使い方

簡単なメモアプリを題材にして Isar Database の使い方を紹介します!
メモアプリの要件は次の通りです。

  • メモの一覧を更新日時の降順で表示する
  • メモを登録、更新、削除ができる
  • メモをカテゴリにわけて管理できる
  • カテゴリはあらかじめ用意しておく(仕事、プライベート、その他の3種類とする)

デモ

環境

Flutter 3.3.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision e3c29ec00c (4 days ago) • 2022-09-14 08:46:55 -0500
Engine • revision a4ff2c53d8
Tools • Dart 2.18.1 • DevTools 2.15.0

pubspec.yaml を編集する

次のように複数のパッケージを追加します。path_provider は Isar の初期化時にデータベースファイルの保存先を設定するのに使います。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
+  isar: ^3.0.0
+  isar_flutter_libs: ^3.0.0
+  path_provider: ^2.0.11

dev_dependencies:
+  build_runner: ^2.2.1
  flutter_lints: ^2.0.0
  flutter_test:
    sdk: flutter
+  isar_generator: ^3.0.0

コレクションを作成する

コレクションとは RDB でいうテーブルのことで、今回はカテゴリとメモの 2 つのコレクションを作成していきます。

カテゴリコレクション

collections/category.dart
import 'package:isar/isar.dart';

part 'category.g.dart';

()
class Category {
  /// 自動インクリメントする ID
  Id id = Isar.autoIncrement;

  /// カテゴリ名
  late String name;
}
  • Isar ジェネレータが自動生成クラスを作れるように @Collection アノテーションを付けます。
  • コレクションを一意に識別する Id フィールドを定義します。
  • 今回は Id id = Isar.autoIncrement; で自動インクリメントする Id フィールドを用意しました。

メモコレクション

collections/memo.dart
import 'package:isar/isar.dart';

import 'category.dart';

part 'memo.g.dart';

()
class Memo {
  /// 自動インクリメントする ID
  Id id = Isar.autoIncrement;

  /// カテゴリ
  final category = IsarLink<Category>();

  /// メモの内容
  late String content;

  /// 作成日時
  late DateTime createdAt;

  /// 更新日時
  ()
  late DateTime updatedAt;
}
  • メモはひとつのカテゴリを持つので IsarLink でリンクします。
  • メモ一覧は更新日時の降順で表示するので updatedAt@Index() アノテーションを付けます。これによりあらかじめ updatedAt で並び替えて DB に保存されるのでメモ一覧の表示が高速化されます。

コードジェネレーターを実行する

次のコマンドを実行してコレクションクラスの自動生成ファイルを生成します。

flutter pub run build_runner build --delete-conflicting-outputs

実行すると、次のように *.g.dart ファイルが自動生成されます。

.
└── collections
    ├── category.dart
    ├── category.g.dart
    ├── memo.dart
    └── memo.g.dart

Isar インスタンスを生成する

コレクションを操作するための Isar インスタンスを準備しましょう!

main.dart
WidgetsFlutterBinding.ensureInitialized();

var path = '';
if (!kIsWeb) {
  final dir = await getApplicationSupportDirectory();
  path = dir.path;
}

final isar = await Isar.open(
  [
    CategorySchema,
    MemoSchema,
  ],
  directory: path,
);
  • Isar.open() で Isar インスタンスを生成します。
    • schemas には自動生成されたコレクションのスキーマクラスを指定します。
    • directory には path_providergetApplicationSupportDirectory() を実行して得たディレクトリパスを指定します。
      • path_provider は Web に非対応のため、Web の場合は空文字を渡してあげます。

これで準備は完了です!次は isar インスタンスを使って様々な操作をしていきます。

メモ一覧を取得する

// 更新日時の降順で全件返す
final allMemos = await isar.memos.where().sortByUpdatedAtDesc().findAll();

// IsarLink でリンクされているカテゴリを読み込む必要がある
for (final memo in allMemos) {
  await memo.category.load();
}
  • 上記は where() を使っていますが、filter() を使ってより直感的にクエリを構築することも出来ます。
  • IsarLink でリンクされたカテゴリは load() を使って読み込む必要があります。

メモ一覧を監視する

次のようにメモ一覧に変化(追加、更新、削除)があったら通知を受けることが出来ます。

isar.memos.watchLazy().listen((_) async {
  // 追加、更新、削除などの変化があった
});

メモを追加する

final now = DateTime.now();
final memo = Memo()
  ..category.value = category
  ..content = content
  ..createdAt = now
  ..updatedAt = now;
await isar.writeTxn(() async {
  await isar.memos.put(memo);

  // IsarLinkでリンクされているカテゴリを保存する必要がある
  await memo.category.save();
});
  • Memo インスタンスに値をつめて put(memo) で追加します。
  • DB に対する操作は await isar.writeTxn(() async {}); で括る必要があります。
  • IsarLink でリンクされたカテゴリは save() を使って保存する必要があります。

メモを更新する

final now = DateTime.now();
memo
  ..category.value = category
  ..content = content
  ..updatedAt = now;
await isar.writeTxn(() async {
  await isar.memos.put(memo);

  // IsarLinkでリンクされているカテゴリを保存する必要がある
  await memo.category.save();
});
  • Memo インスタンスの各フィールドを更新したい値で上書きし、追加と同じ put(memo) で更新します。
  • IsarLink でリンクされたカテゴリは save() を使って保存する必要があります。

メモを削除する

await isar.writeTxn(() async {
  return isar.memos.delete(memo.id);
});
  • delete(id) で削除します。

これでメモに対する CURD が出来るようになりました!

メモアプリを作成してみる

Isar Database の使い方が分かったところで、メモアプリに組み込んでみます!
今回は次の図のような簡易的な リポジトリパターン で実装してみます。実際の現場では Riverpod などを使って依存性注入 ( DI ) したり状態管理をして保守性を高めますが、今回は Isar Database のサンプルと言うことで状態管理パッケージは使いません。

アーキテクチャ図

メモリポジトリクラスを作成する

上で作った CURD の処理をメソッドに落とし込んだ MemoRepository クラスを作成します。コンストラクタでメモ一覧の監視を開始して、イベントを受けたらメモ一覧を取得してストリームに流すようにしています。

memo_repository.dart
/// メモリポジトリ
///
/// メモに関する操作はこのクラスを経由して行う
class MemoRepository {
  MemoRepository(this.isar) {
    // メモ一覧の変化を監視してストリームに流す
    isar.memos.watchLazy().listen((_) async {
      if (!isar.isOpen) {
        return;
      }
      if (_memoStreamController.isClosed) {
        return;
      }
      _memoStreamController.sink.add(await findMemos());
    });
  }

  /// Isarインスタンス
  final Isar isar;

  /// メモ一覧を監視したい場合はmemoStreamをlistenしてもらう
  final _memoStreamController = StreamController<List<Memo>>.broadcast();
  Stream<List<Memo>> get memoStream => _memoStreamController.stream;

  /// 終了処理
  void dispose() {
    _memoStreamController.close();
  }

  /// カテゴリを検索する
  Future<List<Category>> findCategories() async {
    if (!isar.isOpen) {
      return [];
    }

    // デフォルトのソートはidの昇順
    return isar.categorys.where().findAll();
  }

  /// メモを検索する
  Future<List<Memo>> findMemos() async {
    if (!isar.isOpen) {
      return [];
    }

    // 更新日時の降順で全件返す
    final memos = await isar.memos.where().sortByUpdatedAtDesc().findAll();

    // IsarLinkでリンクされているカテゴリを読み込む必要がある
    for (final memo in memos) {
      await memo.category.load();
    }
    return memos;
  }

  /// メモを追加する
  Future<void> addMemo({
    required Category category,
    required String content,
  }) {
    if (!isar.isOpen) {
      return Future<void>(() {});
    }

    final now = DateTime.now();
    final memo = Memo()
      ..category.value = category
      ..content = content
      ..createdAt = now
      ..updatedAt = now;
    return isar.writeTxn(() async {
      await isar.memos.put(memo);

      // IsarLinkでリンクされているカテゴリを保存する必要がある
      await memo.category.save();
    });
  }

  /// メモを更新する
  Future<void> updateMemo({
    required Memo memo,
    required Category category,
    required String content,
  }) {
    if (!isar.isOpen) {
      return Future<void>(() {});
    }

    final now = DateTime.now();
    memo
      ..category.value = category
      ..content = content
      ..updatedAt = now;
    return isar.writeTxn(() async {
      await isar.memos.put(memo);

      // IsarLinkでリンクされているカテゴリを保存する必要がある
      await memo.category.save();
    });
  }

  /// メモを削除する
  Future<bool> deleteMemo(Memo memo) async {
    if (!isar.isOpen) {
      return false;
    }

    return isar.writeTxn(() async {
      return isar.memos.delete(memo.id);
    });
  }
}

メモ一覧を画面に表示する

画面を表示したときに上で作成した MemoRepository クラスを使ってメモ一覧を取得して表示する MemoIndexPage クラスを作成してみます!

memo_index_page.dart
/// メモ一覧画面
class MemoIndexPage extends StatefulWidget {
  const MemoIndexPage({
    super.key,
    required this.memoRepository,
  });

  /// メモリポジトリ
  final MemoRepository memoRepository;

  
  State<MemoIndexPage> createState() => MemoIndexPageState();
}


class MemoIndexPageState extends State<MemoIndexPage> {
  /// 表示するメモ一覧
  final memos = <Memo>[];

  
  void initState() {
    super.initState();

    /// メモ一覧を監視して変化があれば画面を更新する
    widget.memoRepository.memoStream.listen(_refresh);

    // メモ一覧を取得して画面を更新する
    () async {
      _refresh(await widget.memoRepository.findMemos());
    }();
  }

  /// メモ一覧画面を更新する
  void _refresh(List<Memo> memos) {
    if (!mounted) {
      return;
    }

    setState(() {
      this.memos
        ..clear()
        ..addAll(memos);
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('メモ'),
      ),
      body: ListView.builder(
        itemBuilder: (context, index) {
          final memo = memos[index];
          final category = memo.category.value;
          return ListTile(
            title: Text(memo.content),
            subtitle: Text(category?.name ?? ''),
          );
        },
        itemCount: memos.length,
      ),
    );
  }
}

MemoRepository から取得したメモ一覧を保持するため StatefulWidget にします。画面が表示されたときに memoRepository.findMemos() を呼んでメモ一覧を取得し、画面を更新しています。また memoRepository.memoStream を監視してメモ一覧に変化があれば都度画面に反映するようにしています。

メモを追加および更新する

今回はカテゴリ選択ドロップダウンボタンとメモ内容を入力するテキストフィールドを持つダイアログを作成します。メモの追加と更新は同じ UI で対応ができるので、コンストラクタに memo を受け取るようにして、memo があれば更新、無ければ登録するようにします。

memo_index_page.dart
/// メモ登録/更新ダイアログ
class MemoUpsertDialog extends StatefulWidget {
  const MemoUpsertDialog(
    this.memoRepository, {
    super.key,
    this.memo,
  });

  /// メモリポジトリ
  final MemoRepository memoRepository;

  /// 更新するメモ(登録時はnull)
  final Memo? memo;

  
  State<MemoUpsertDialog> createState() => MemoUpsertDialogState();
}


class MemoUpsertDialogState extends State<MemoUpsertDialog> {
  /// 表示するカテゴリ一覧
  final categories = <Category>[];

  /// 選択中のカテゴリ
  Category? _selectedCategory;
  Category? get selectedCategory => _selectedCategory;

  /// 入力中のメモコンテンツ
  final _textController = TextEditingController();
  String get content => _textController.text;

  
  void initState() {
    super.initState();

    () async {
      // カテゴリ一覧を取得する
      categories.addAll(await widget.memoRepository.findCategories());

      // 初期値を設定する
      _selectedCategory = categories.firstWhere(
        (category) => category.id == widget.memo?.category.value?.id,
        orElse: () => categories.first,
      );
      _textController.text = widget.memo?.content ?? '';

      // 再描画する
      setState(() {});
    }();
  }

  
  Widget build(BuildContext context) {
    return AlertDialog(
      content: SingleChildScrollView(
        child: Column(
          children: [
            // カテゴリはドロップボタンで選択する
            DropdownButton<Category>(
              value: _selectedCategory,
              items: categories
                  .map(
                    (category) => DropdownMenuItem<Category>(
                      value: category,
                      child: Text(category.name),
                    ),
                  )
                  .toList(),
              onChanged: (category) {
                setState(() {
                  _selectedCategory = category;
                });
              },
              isExpanded: true,
            ),
            TextField(
              controller: _textController,
              onChanged: (_) {
                // 「保存」ボタンの活性化/非活性化を更新するために画面更新する
                setState(() {});
              },
            ),
          ],
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('キャンセル'),
        ),
        TextButton(
          // 入力中のメモコンテンツが1文字以上あるときだけ「保存」ボタンを活性化する
          onPressed: content.isNotEmpty
              ? () async {
                  final memo = widget.memo;
                  if (memo == null) {
                    // 登録処理
                    await widget.memoRepository.addMemo(
                      category: _selectedCategory!,
                      content: content,
                    );
                  } else {
                    // 更新処理
                    await widget.memoRepository.updateMemo(
                      memo: memo,
                      category: _selectedCategory!,
                      content: content,
                    );
                  }
                  if (mounted) {
                    Navigator.of(context).pop();
                  }
                }
              : null,
          child: const Text('保存'),
        ),
      ],
    );
  }
}

メモを削除する

最後に、メモを削除できるようにします。メモを表示する ListTile に削除ボタンを表示して、押下されたら memoRepository.deleteMemo() を呼んで削除します。

memo_index_page.dart
return ListTile(
  title: Text(memo.content),
  subtitle: Text(category?.name ?? ''),
  // 削除ボタン押下されたらメモを即削除する
  trailing: IconButton(
    onPressed: () => widget.memoRepository.deleteMemo(memo),
    icon: const Icon(Icons.close),
  ),
);

これでメモアプリの完成です!

メモアプリのコードは公開しています

メモアプリのコードは下記で公開しています!下記コードでは本記事で紹介しきれていない同期( async / await なし)バージョンが試せたり、JSONで定義した初期値を Isar Databae に書き込む処理があったりしてます。参考になれば幸いです!

https://github.com/susatthi/flutter-sample-isar

次の記事では、今回作成したメモアプリの単体テスト/Widgetテストの書き方について紹介したいと思います!

最後に

Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!

https://flutteruniv.com?invite_id=9hsdZHg0qtaMIr6RPRulAaRJfA83

あわせて読みたい

https://isar.dev/tutorials/quickstart.html

https://zenn.dev/slowhand/articles/3a14cce00e0181

https://zenn.dev/ken1flan/scraps/e31ea62bff0e40

Discussion