【Flutter】IsarとRiverpodで実現するリアルタイムデータ同期の実装

## はじめに

Flutterにおけるローカルデータベースパッケージ、Isarについて学んでいます。
こちらは前回書いた記事の続きとなります。

https://qiita.com/Harx02663971/items/76d0121e7ac240a97e99

https://qiita.com/Harx02663971/items/b9078ed29bd9b86cf44f

今回の主なテーマは以下の内容です。

1. 過去プロジェクトのデータ取得方法の問題点
2. Isarデータベースの変更を監視する
3. AsyncValueで状態をリアルタイムに反映する

### 記事の対象者

- Isarを使ってローカルデータベースを構築してみたい方
- Isarがどんなものかを知りたい方
- Flutterの学習を数ヶ月行った方
- riverpodの知識がある程度ある方
- build_runnerの知識がある程度ある方

### 記事を執筆時点での筆者の環境

```
[✓] Flutter (Channel stable, 3.24.1, on macOS 14.5 23F79 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.92.2)
```

### サンプルプロジェクト

![second_contact.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/c9ff4109-abfa-f4ab-3e4b-592f8080e7ca.gif)

*前回からあった機能*
1. アプリを起動すると保存されているユーザー情報を取得して表示する
1. ユーザー情報が載っているリストタイルをタップすると名前(とid)以外をランダムに更新する
1. ユーザー情報が載っているリストタイルをロングタップすると対象のユーザー情報を削除する
1. プラスボタンをタップして３つの新規作成パターンを選べる
    1. 一つのUserデータを新規作成する
    1. あらかじめ指定した数のUserデータを*同期処理*で新規作成する
    1. あらかじめ指定した数のUserデータを*非同期処理*で新規作成する
1. 炎ボタンをタップして３つの削除処理パターンを選べる
    1. 全てのUserデータを削除する処理する
    1. あらかじめ指定した数のUserデータを*同期処理*で削除する
    1. あらかじめ指定した数のUserデータを*非同期処理*で削除する

**今回追加した機能**
データベースの更新を自動で取得し、リアルタイムで反映するように変更

### ソースコード

https://github.com/HaruhikoMotokawa/isar_sample/tree/feature/third_contact

※ feature/third_contactブランチです

## 1. 過去プロジェクトのデータ取得方法の問題点

過去のプロジェクトにおいて、データを取得して表示することやデータが更新された後に最新の情報を表示することはできています。
しかし、いくつかの問題を引き起こす可能性も秘めいています。

### 1-1. 更新を忘れてしまう可能性

```lib/presentations/home_screen/home_view_model.dart
/// ホーム画面のViewModel
@riverpod
class HomeViewModel extends _$HomeViewModel {
  @override
  void build() {}
  final _stopwatch = Stopwatch();

  /// ユーザー情報の作成と取得
  Future<List<User>> createAndFetchUser() async {
    final user = User.random();
    _stopwatch.start();
    await ref.read(userRepositoryProvider).save(user);
    _stopwatch.stop();
    logger.i('save: ${_stopwatch.elapsedMilliseconds}ms');
    return fetchAllUsers();
  }

  /// ユーザー情報の取得
  Future<List<User>> fetchAllUsers() async {
    _stopwatch.start();
    final list = await ref.read(userRepositoryProvider).findAll();
    _stopwatch.stop();
    logger.i('findAll: ${_stopwatch.elapsedMilliseconds}ms');
    return list;
  }

  /// ユーザー情報の更新と取得
  ///
  /// 更新内容はidとname以外の項目をランダムに更新する
  Future<List<User>> updateAndFetchUser(User user) async {
    final updatedUser = user.createUpdatedUser();
    _stopwatch.start();
    await ref.read(userRepositoryProvider).save(updatedUser);
    _stopwatch.stop();
    logger.i('save: ${_stopwatch.elapsedMilliseconds}ms');
    return fetchAllUsers();
  }
  
  // 省略
}
```
上記はfeature/secound_contactブランチの処理内容です。
何かしらの変更を加えた場合に必ずデータ最新のデータを返すような設計にしてあります。
`createAndFetchUser`メソッドを例にとると、新規でデータを作成して保存した最後に、
最新のUser情報一覧を取得して返却しています。

データベスへの変更が一つならまだしも、複数存在する場合に毎回入れるのは面倒です。
また、この処理を入れ忘れてしまうと最新の情報を取得できないという危険も孕んでいます。

### 1-2. データを最新にする処理が分割される

画面に表示するユーザー情報一覧を`useState`にしています。
そのため、アプリ起動時の情報取得を`useEffect`で行なっています。
```lib/presentations/home_screen/home_screen.dart
class HomeScreen extends HookConsumerWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.watch(homeViewModelProvider.notifier);

    final users = useState<List<User>>([]);

    useEffect(
      () {
        viewModel.fetchAllUsers().then((fetchUsers) {
          users.value = fetchUsers;
        });
        return null;
      },
      [],
    );
    return Scaffold(

    // 省略

      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [

          // 省略
          
          FloatingActionButton(
            onPressed: () => createUserAction(context, viewModel, users),
            backgroundColor: Colors.lightBlueAccent,
            child: const Icon(Icons.add),
          ),
        ],
      ),
    );
  }
}
```
状態を`useState`にしたことによってユーザー情報を更新した場合はUI側に更新を伝えなければいけません

```lib/presentations/home_screen/home_screen.dart
extension on HomeScreen {
  /// ユーザーを追加するアクション
  Future<void> createUserAction(
    BuildContext context,
    HomeViewModel viewModel,
    ValueNotifier<List<User>> users,
  ) async {
    // 省略
    
    // 作成するユーザー数
    const number = 100000;
    try {
      // ユーザーを作成して取得
      switch (result) {
        case CreateActionType.single:
          // 💡 ここでuseStateに最新情報を再代入している
          users.value = await viewModel.createAndFetchUser();
        case CreateActionType.batchUseSync:
          // 💡 ここでuseStateに最新情報を再代入している
          users.value = await viewModel.createBatchAndFetchUser(
            useSync: true,
            number: number,
          );
        case CreateActionType.batchUseAsync:
          // 💡 ここでuseStateに最新情報を再代入している
          users.value = await viewModel.createBatchAndFetchUser(
            useSync: false,
            number: number,
          );
      }
      // 省略    
  }

  // 省略

}

enum CreateActionType {
  single,
  batchUseSync,
  batchUseAsync,
}
```
上記のように更新処理の際に変更した情報を`useState`に再度代入しています。
これでは`HomeScreen`と`HomeViewModel`で交互に更新、更新後の情報再取得と
関心ごとが分割されて複雑になってしまいます。

### 1-3. やりたういこと

1. ViewModelではデータの作成、更新、削除などの操作のみで完結したい
1. Screenではユーザー情報が更新されたらその変更を受け取って更新する仕組みに変えたい

## 2. Isarデータベースの変更を監視する

データベースの変更を監視してリアルタイムに通知する仕組みがIsarには用意されています。
それがウォッチャーです。

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


### 2-1. Isarのウォッチャーの種類

公式のドキュメントには監視対象別に３つの方法を紹介しています。

**オブジェクトの監視**
一つのオブジェクトを監視します。
今回のサンプルプロジェクトに照らし合わせると、１つのユーザーデータに対する変更を監視します。

オブジェクトの監視では２つのオプションが用意されており、変更されたオブジェクトをそのまま返却する
`watchObject`と、変更があったことを通知するだけの`watchObjectLazy`があります。

**コレクションの監視**
コレクション、つまりユーザーデータ全体を監視します。
コレクションの監視の場合は変更があったことを通知するだけの`watchLazy`のみがあります。

::: note 
今回はこちらを採用します。
:::

**クエリの監視**
クエリとはある条件に基づいて絞り込まれたデータの集まりです。
例えばユーザー情報の中で、ホームタウンがSendaiの人だけを絞り込んだ情報の変更を監視する、といった場合です。
こちらは値の取得である`watch`と、変更通知のみの`watchLazy`の両方に対応しています。

### 2-2. リポジトリ層に変更を配信するStreamメソッドを実装する

```lib/data/repositories/user_repository/repository.dart
// 必要箇所のみ抜粋

class UserRepository implements UserRepositoryBase {
  UserRepository(this.ref);

  final ProviderRef<dynamic> ref;

  @override
  Future<List<User>> findAll() async {
    final isar = await ref.read(isarProvider.future);
    final userEntitys = await isar.userEntitys.where().findAll();
    return userEntitys.map((entity) => entity.toDomain()).toList();
  }

  @override
  Stream<List<User>> watch() async* {
    final isar = await ref.read(isarProvider.future);
    // watchLazy で変更を監視
    final userStream = isar.userEntitys.watchLazy(fireImmediately: true);
    await for (final _ in userStream) {
      yield await findAll();
    }
  }
```
`Stram`は通常であれば最初に初期値を`yield`で流します。
しかし、`watchLazy`の引数に`fireImmediately: true`を入れておくと変更があった場合即座に`Stream`へ値を流してくれます。

先述した通り、コレクションウォッチャーの場合は変更があったことだけしか通知値てくれません。
今回の場合でくと何かしらの変更があった場合は`findAll`を実行してデータを取得し直して`Stream`に流しています。

## 3. AsyncValueで状態をリアルタイムに反映する
リポジトリ層で作った`watch`メソッドを使って表題の件を実現していきます。

### 3-1. riverpodでグローバルな状態を定義する
```lib/data/repositories/user_repository/provider.dart
@riverpod
Stream<List<User>> userList(UserListRef ref) {
  return ref.read(userRepositoryProvider).watch();
}
```

状態の取得はすべてこのプロバイダー経由で行います。

### 3-2. ViewModelのメソッドの変更


まず情報を取得するメソッドが必要なくなったので削除します。

![スクリーンショット 2024-09-22 22.43.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/d08344d3-53e2-6b4a-5984-2adea66d0e41.png)

次に何かしらの変更を加えるメソッドの最後に行なっていた情報の取得処理をすべて無くします。
最後にすべてのメソッドの戻り値を`List<User>`から`<void>`に変更します。
![スクリーンショット 2024-09-22 22.49.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/929d507d-baa3-7720-97ad-7cdc78cb8ef9.png)

```lib/presentations/home_screen/home_view_model.dart
  /// ユーザー情報の作成
  Future<void> create() async {
    final user = User.random();
    _stopwatch.start();
    await ref.read(userRepositoryProvider).save(user);
    _stopwatch.stop();
    logger.i('save: ${_stopwatch.elapsedMilliseconds}ms');
  }
```

ユーザー情報を作成することだけに集中できるようになり、処理を完結にすることができました。

### 3-3. `HomeScreen`の再設計

`userListProvider`を使ってユーザー情報一覧を取得することで、以下の変更を加えます。
1. `useState`と`useEffect`の廃止
1. `HookWidget`から`ConsumerWidgeet`へ変更
1. `userListProvider`の`watch`と`build`内のウィジェット構築を`when`で分岐
1. それぞれのアクションで`useState`に代入していた部分を削除

まずは1,2,3の項目を以下で見てみます。


![スクリーンショット 2024-09-22 23.04.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/64f6c930-8acb-2a86-57c3-6a45293f76da.png)

`useEffect`を使わなくなったことで情報を取得するロジックがなくなり、UIに集中することができました。

<details><summary>コード全体はこちら</summary>

```lib/presentations/home_screen/home_screen.dart
class HomeScreen extends ConsumerWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.watch(homeViewModelProvider.notifier);

    final userList = ref.watch(userListProvider);

    return userList.when(
      data: (value) => Scaffold(
        appBar: AppBar(
          title: const Text('isar_sample'),
          backgroundColor: Colors.lightBlueAccent,
        ),
        body: ListView.builder(
          itemCount: value.length,
          itemBuilder: (BuildContext context, int index) {
            return Container(
              decoration: BoxDecoration(
                border: Border(
                  top: const BorderSide(color: Colors.grey),
                  bottom: index == value.length - 1
                      ? const BorderSide(color: Colors.grey)
                      : BorderSide.none,
                ),
              ),
              child: UserListTile(
                user: value[index],
                onTap: () => updateUserAction(
                  context,
                  viewModel,
                  value[index],
                ),
                onLongPress: () => deleteUserAction(
                  context,
                  viewModel,
                  value[index],
                ),
              ),
            );
          },
        ),
        floatingActionButton: Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            FloatingActionButton(
              onPressed: () => deleteUsersAction(context, viewModel, value),
              backgroundColor: Colors.lightBlueAccent,
              child: const Icon(Icons.local_fire_department),
            ),
            const Gap(10),
            FloatingActionButton(
              onPressed: () => createUserAction(context, viewModel),
              backgroundColor: Colors.lightBlueAccent,
              child: const Icon(Icons.add),
            ),
          ],
        ),
      ),
      error: (error, stackTrace) => Scaffold(
        body: Center(
          child: Text('error; $error, stackTrace: $stackTrace'),
        ),
      ),
      loading: () => const Scaffold(
        body: Center(
          child: CircularProgressIndicator(
            backgroundColor: Colors.lightBlueAccent,
            color: Colors.white,
          ),
        ),
      ),
    );
  }
}
```

</details>

また４番目についてはユーザーの新規作成の部分で見てみましょう。

![スクリーンショット 2024-09-22 23.13.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/59c47ac0-bb93-8fb0-21a3-ec61a704d63b.png)

`useState`に新しいユーザー情報一覧を代入しなくて良くなったので、メソッドの引数の低減、処理内容の簡略化ができました。
<details><summary>コード全体はこちら</summary>

```lib/presentations/home_screen/home_view_model.dart
/// ユーザーを追加するアクション
  Future<void> createUserAction(
    BuildContext context,
    HomeViewModel viewModel,
  ) async {
    // ボトムシートを展開してアクションを選択する
    final result = await ActionBottomSheet.show<CreateActionType>(
      context,
      actions: [
        ActionItem(
          icon: Icons.person,
          text: 'Single',
          onTap: () => CreateActionType.single,
        ),
        ActionItem(
          icon: Icons.people,
          text: 'Batch',
          onTap: () => CreateActionType.batchUseSync,
        ),
        ActionItem(
          icon: Icons.people,
          text: 'Batch(Async)',
          onTap: () => CreateActionType.batchUseAsync,
        ),
      ],
    );

    if (result == null || !context.mounted) return;

    // オーバーレイにインジケーターを表示
    final overlay = Overlay.of(context);
    final overlayEntry = OverlayEntry(
      builder: (context) => const Center(
        child: CircularProgressIndicator(),
      ),
    );
    overlay.insert(overlayEntry);

    // 作成するユーザー数
    const number = 100000;
    try {
      // ユーザーを作成
      switch (result) {
        case CreateActionType.single:
          await viewModel.create();
        case CreateActionType.batchUseSync:
          await viewModel.createBatch(
            useSync: true,
            number: number,
          );
        case CreateActionType.batchUseAsync:
          await viewModel.createBatch(
            useSync: false,
            number: number,
          );
      }

      // インジケーターを閉じる
      overlayEntry.remove();

      if (!context.mounted) return;
      // スナックバーを表示
      switch (result) {
        case CreateActionType.single:
          showSnackBar(context, 'ユーザーを新規作成しました');
        case CreateActionType.batchUseSync:
          showSnackBar(context, 'ユーザーを同期処理で$number個作成しました');
        case CreateActionType.batchUseAsync:
          showSnackBar(context, 'ユーザーを非同期処理で$number個作成しました');
      }
    } catch (e, s) {
      logger.e('エラー発生', error: e, stackTrace: s);

      // インジケーターを閉じる
      overlayEntry.remove();

      if (!context.mounted) return;
      showSnackBar(context, 'エラーが発生しました');
    }
  }
```

</details>

## 終わりに

今回の記事では、Isarデータベースの変更をリアルタイムに監視し、AsyncValueを活用して状態を自動的に反映する方法について解説しました。
これにより、データの更新処理をより簡潔かつ効率的に実装することができ、ユーザー体験の向上にもつながります。
従来の手動で最新データを取得する方式と比較して、よりシンプルで直感的なアプローチを採用できたと思います。

この記事がIsarを学ぶ方の一助になれば幸いです。