はじめに
Flutterにおけるローカルデータベースパッケージ、Isarについて学んでいます。
こちらは前回書いた記事の続きとなります。
今回の主なテーマは以下の内容です。
- 過去プロジェクトのデータ取得方法の問題点
- Isarデータベースの変更を監視する
- 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)
サンプルプロジェクト
前回からあった機能
- アプリを起動すると保存されているユーザー情報を取得して表示する
- ユーザー情報が載っているリストタイルをタップすると名前(とid)以外をランダムに更新する
- ユーザー情報が載っているリストタイルをロングタップすると対象のユーザー情報を削除する
- プラスボタンをタップして3つの新規作成パターンを選べる
- 一つのUserデータを新規作成する
- あらかじめ指定した数のUserデータを同期処理で新規作成する
- あらかじめ指定した数のUserデータを非同期処理で新規作成する
- 炎ボタンをタップして3つの削除処理パターンを選べる
- 全てのUserデータを削除する処理する
- あらかじめ指定した数のUserデータを同期処理で削除する
- あらかじめ指定した数のUserデータを非同期処理で削除する
今回追加した機能
データベースの更新を自動で取得し、リアルタイムで反映するように変更
ソースコード
※ feature/third_contactブランチです
1. 過去プロジェクトのデータ取得方法の問題点
過去のプロジェクトにおいて、データを取得して表示することやデータが更新された後に最新の情報を表示することはできています。
しかし、いくつかの問題を引き起こす可能性も秘めいています。
1-1. 更新を忘れてしまう可能性
/// ホーム画面の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
で行なっています。
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側に更新を伝えなければいけません
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. やりたういこと
- ViewModelではデータの作成、更新、削除などの操作のみで完結したい
- Screenではユーザー情報が更新されたらその変更を受け取って更新する仕組みに変えたい
2. Isarデータベースの変更を監視する
データベースの変更を監視してリアルタイムに通知する仕組みがIsarには用意されています。
それがウォッチャーです。
2-1. Isarのウォッチャーの種類
公式のドキュメントには監視対象別に3つの方法を紹介しています。
オブジェクトの監視
一つのオブジェクトを監視します。
今回のサンプルプロジェクトに照らし合わせると、1つのユーザーデータに対する変更を監視します。
オブジェクトの監視では2つのオプションが用意されており、変更されたオブジェクトをそのまま返却する
watchObject
と、変更があったことを通知するだけのwatchObjectLazy
があります。
コレクションの監視
コレクション、つまりユーザーデータ全体を監視します。
コレクションの監視の場合は変更があったことを通知するだけのwatchLazy
のみがあります。
今回はこちらを採用します。
クエリの監視
クエリとはある条件に基づいて絞り込まれたデータの集まりです。
例えばユーザー情報の中で、ホームタウンがSendaiの人だけを絞り込んだ情報の変更を監視する、といった場合です。
こちらは値の取得であるwatch
と、変更通知のみのwatchLazy
の両方に対応しています。
2-2. リポジトリ層に変更を配信するStreamメソッドを実装する
// 必要箇所のみ抜粋
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でグローバルな状態を定義する
@riverpod
Stream<List<User>> userList(UserListRef ref) {
return ref.read(userRepositoryProvider).watch();
}
状態の取得はすべてこのプロバイダー経由で行います。
3-2. ViewModelのメソッドの変更
まず情報を取得するメソッドが必要なくなったので削除します。
次に何かしらの変更を加えるメソッドの最後に行なっていた情報の取得処理をすべて無くします。
最後にすべてのメソッドの戻り値をList<User>
から<void>
に変更します。
/// ユーザー情報の作成
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
を使ってユーザー情報一覧を取得することで、以下の変更を加えます。
-
useState
とuseEffect
の廃止 -
HookWidget
からConsumerWidgeet
へ変更 -
userListProvider
のwatch
とbuild
内のウィジェット構築をwhen
で分岐 - それぞれのアクションで
useState
に代入していた部分を削除
まずは1,2,3の項目を以下で見てみます。
useEffect
を使わなくなったことで情報を取得するロジックがなくなり、UIに集中することができました。
コード全体はこちら
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,
),
),
),
);
}
}
また4番目についてはユーザーの新規作成の部分で見てみましょう。
useState
に新しいユーザー情報一覧を代入しなくて良くなったので、メソッドの引数の低減、処理内容の簡略化ができました。
コード全体はこちら
/// ユーザーを追加するアクション
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, 'エラーが発生しました');
}
}
終わりに
今回の記事では、Isarデータベースの変更をリアルタイムに監視し、AsyncValueを活用して状態を自動的に反映する方法について解説しました。
これにより、データの更新処理をより簡潔かつ効率的に実装することができ、ユーザー体験の向上にもつながります。
従来の手動で最新データを取得する方式と比較して、よりシンプルで直感的なアプローチを採用できたと思います。
この記事がIsarを学ぶ方の一助になれば幸いです。