はじめに
Flutterにおけるローカルデータベースパッケージ、Isarについて学んでいます。
こちらは前回書いた記事の続きとなります。
今回の主なテーマは以下の内容です。
- Isarで保存できるenumについて
- Isarで保存できる埋め込み型オブジェクトについて
- 複数のデータを保存、削除する
- 同期と非同期
記事の対象者
- 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)以外をランダムに更新する
- ユーザー情報が載っているリストタイルをロングタップすると対象のユーザー情報を削除する
今回追加した機能
- User情報に新たなパラメータを追加
- プラスボタンをタップして3つの新規作成パターンを選べるように変更
- 一つのUserデータを新規作成する
- あらかじめ指定した数のUserデータを同期処理で新規作成する
- あらかじめ指定した数のUserデータを非同期処理で新規作成する
- 炎ボタンをタップして3つの削除処理パターンを選べるように変更
- 全てのUserデータを削除する処理する
- あらかじめ指定した数のUserデータを同期処理で削除する
- あらかじめ指定した数のUserデータを非同期処理で削除する
ソースコード
※ feature/second_contactブランチです
1. Isarで保存できるEnumについて
Isarではenumを保存する際に幾つかのオプションが提供されています。
これはIsarのデータベースにどのような形で保存するかということです。
EnumType | Description |
---|---|
ordinal | 列挙型のインデックスは byte として格納されます。これは非常に効率的ですが、null 値を許容する enum は使用できません。 |
ordinal32 | 列挙型のインデックスは short (4 バイトの整数) として格納されます。 |
name | 列挙名称は String として格納されます。 |
value | 列挙値の取得には、カスタムプロパティを使用します。 |
しかし、公式には以下のように書かれています。
ordinal と ordinal32 は、列挙された値の順番に依存します。
この順序を変更すると、既存のデータベースは不正な値を返す可能性があります。
つまり、後からEnum
の中の順番を変更するとエラーを引き起こす可能性があるということです。
またnull許容できるかどうかなどの柔軟性も含めると、name
またはvalue
が使い勝手としては良さそうです。
name
だとString
型で保存します。
value
だとEnum
のフィールドの型で保存されます。
データを検索する際の速度に関わってくると思われますが、よっぽど大量のデータではない限りname
での保存で良いと考えます。
1-1. EnumType.name
enum HomeTown {
Tokyo,
Osaka,
Kyoto,
Sapporo,
Fukuoka,
Sendai,
}
enum Pet {
dog('Dog', 5),
cat('Cat', 3),
rabbit('Rabbit', 2),
parrot('Parrot', 4),
hamster('Hamster', 1);
const Pet(this.species, this.age);
final String species; // ペットの種類
final int age; // ペットの年齢
}
import 'package:isar/isar.dart';
import 'package:isar_sample/domains/dragon_ball_character.dart';
import 'package:isar_sample/domains/home_town.dart';
import 'package:isar_sample/domains/pet.dart';
part 'user_entity.g.dart'; // Isarコードジェネレーターが生成するファイル
/// Isarで扱うユーザーの情報の型
// Isarのエンティティクラスには、@collectionをつける
@collection
// @nameをつけることで、Isarのコレクション名を指定できる
@Name('User')
class UserEntity {
// 省略
// EnumTypeを指定することで、列挙型を保存できる
@Enumerated(EnumType.name)
late HomeTown homeTown;
/// 飼っているペット達
///
/// Enumをリストでも保存できる
/// EnumType.nameで保存することでデータベース上には文字列として保存される
@Enumerated(EnumType.name)
late List<Pet>? pets;
// 省略
}
HomeTown
のようにフィールドを持っていないEnum
でも良いですし、複数のフィールドを持つ場合のPet
の場合でも保存できます。
また、Enum
のList
型でも保存できますし、null
許容でも大丈夫です。
1-2. EnumType.value
enum DragonBallCharacter {
goku(150000000), // 孫悟空
vegeta(120000000), // ベジータ
gohan(100000000), // 孫悟飯
piccolo(60000000), // ピッコロ
frieza(130000000); // フリーザ
const DragonBallCharacter(this.powerLevel);
final int powerLevel; // 戦闘力
}
import 'package:isar/isar.dart';
import 'package:isar_sample/domains/dragon_ball_character.dart';
import 'package:isar_sample/domains/home_town.dart';
import 'package:isar_sample/domains/pet.dart';
part 'user_entity.g.dart'; // Isarコードジェネレーターが生成するファイル
/// Isarで扱うユーザーの情報の型
// Isarのエンティティクラスには、@collectionをつける
@collection
// @nameをつけることで、Isarのコレクション名を指定できる
@Name('User')
class UserEntity {
// 省略
/// ドラゴンボールのキャラクターに例えた戦闘力
///
/// EnumType.valueを指定することで、そのフィールドの値でデータベースに保存される
/// EnumType.valueでするpropertyはStringで指定するのでタイポに注意
@Enumerated(EnumType.value, 'powerLevel')
late DragonBallCharacter dragonBallCharacter;
// 省略
}
この場合、保存されるのはDragonBallCharacter
に設定されたフィールドの値です。
つまり戦闘力であるintが保存されます。
こちらもname
で保存した場合と同じくList
であったりnull
許容もできます。
注意点としては保存する値をString
でハードコーディングしなければいけない点です。
今回でいうと'powerLevel'
を直接打ち込んでいます。
ここでのタイポに注意が必要です。
2. 埋め込み型オブジェクト
IsarはMap
型の値の保存には対応していません。
その代わりにオブジェクト自体を保存することができます。
Isarの公式には埋め込み型オブジェクトと書かれています。
@collection
@Name('User')
class UserEntity {
// 省略
/// ユーザーのスキル
late Skill? skill;
}
/// ユーザーのスキル情報の型
///
/// 埋め込みオブジェクトは、Isarで扱うエンティティクラスに@embeddedをつける
/// 埋め込みオブジェクトの制約としてパラメータにrequiredをつけることができない
@embedded
class Skill {
Skill({
this.name,
this.description,
this.yearsOfExperience,
});
final String? name; // 特技の名前
final String? description; // 特技の説明
final int? yearsOfExperience; // 経験年数
}
保存するには定義したオブジェクトクラスにアノテーションで@embedded
をつけます。
また、制約としてフィールドにrequired
をつけることができない点に注意が必要です。
ちなみにnull
許容にもできます。
3. 複数のデータを保存、削除する
以前の記事ではデータへの操作を単体で行なっていました。
// 単体のデータを保存または更新
@override
Future<void> save(User user) async {
final isar = await ref.read(isarProvider.future);
final userEntity = user.toEntity();
await isar.writeTxn(() async {
await isar.userEntitys.put(userEntity);
});
}
// 単体のデータを削除
@override
Future<void> delete(int userId) async {
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() async {
await isar.userEntitys.delete(userId);
});
}
複数のデータを一括で操作する処理が用意されています。
それがputAll
やdeleteAll
メソッドです。
/// 複数のデータを保存または更新
@override
Future<void> saveBatch(List<User> users, {bool sync = false}) async {
final isar = await ref.read(isarProvider.future);
final userEntities = users.map((user) => user.toEntity()).toList();
// 省略
await isar.writeTxn(() async {
await isar.userEntitys.putAll(userEntities);
});
}
/// 複数のデータを削除
@override
Future<void> deleteBatch(List<int> userIds, {bool sync = false}) async {
final isar = await ref.read(isarProvider.future);
// 省略
await isar.writeTxn(() async {
await isar.userEntitys.deleteAll(userIds);
});
}
使い方は簡単で、操作したいデータを引数としてList
型で渡せば良いだけです。
データの操作は必ずwriteTxn
を使ったトランザクションの中で行います。
4. 同期と非同期
今まで紹介してきたput
やputAll
などのメソッドはすべて非同期処理、Future
メソッドです。
しかし、実はIsarには同期的に書き込みを行うメソッドが用意されています。
それがputAllSync
やdeleteAllSync
などです。
@override
Future<void> saveBatch(List<User> users, {bool sync = false}) async {
final isar = await ref.read(isarProvider.future);
final userEntities = users.map((user) => user.toEntity()).toList();
if (sync) {
isar.writeTxnSync(() { // <<<< asyncついていない
isar.userEntitys.putAllSync(userEntities); // <<<< awaitつけていない
});
} else {
await isar.writeTxn(() async {
await isar.userEntitys.putAll(userEntities);
});
}
}
@override
Future<void> deleteBatch(List<int> userIds, {bool sync = false}) async {
final isar = await ref.read(isarProvider.future);
if (sync) {
isar.writeTxnSync(() { // <<<< asyncついていない
isar.userEntitys.deleteAllSync(userIds); // <<<< awaitつけていない
});
} else {
await isar.writeTxn(() async {
await isar.userEntitys.deleteAll(userIds);
});
}
}
使い方は基本的には非同期型と同じで、トランザクションの中で目的のメソッドを呼び出すだけです。
ただ、注意としては同期の場合のトランザクションは専用のwriteTxnSync
を使う必要があるところです。
それぞれの処理の違いは以下の内容です。
- 同期処理
writeTxnSync
やputAllSync
などの同期処理は、メインスレッド(UIスレッド)で実行されます。
メインスレッドではユーザーインターフェース(UI)も描画しているため、同期的に時間のかかる処理を実行すると、その間UIの更新がブロックされて「かくつく」ように見えることがあります。
これは、メインスレッドが同時に複数の処理を実行できず、順番に処理を行うためです。
結果的に、処理自体はバックグラウンドスレッドを使わないため速く完了しますが、UIのレスポンスが悪くなる可能性があります。 - 非同期処理
一方、writeTxn
やputAll
の非同期処理は、バックグラウンドスレッドで実行されるため、UIの描画に影響を与えません。
バックグラウンドスレッドではUIの処理と並行して重い処理を実行できるので、ユーザーに対してスムーズな操作感を提供することができます。
ただし、バックグラウンドスレッドの開始や切り替えにわずかなオーバーヘッドがあり、そのため処理にかかる時間が少し長くなる傾向にあります。
4-1. 実験
同期と非同期でどれくらいの違いがあるのか、時間やUIが変わるのか計測してログに出して見ました。
/// 必要箇所のみ抜粋
final _stopwatch = Stopwatch();
/// 複数のユーザー情報を作成して取得する
///
/// 引数に同期処理で実行するか非同期処理で実行するか選択できる
Future<List<User>> createBatchAndFetchUser({
required bool useSync,
required int number,
}) async {
final users = List.generate(number, (_) => User.random());
_stopwatch.start();
await ref.read(userRepositoryProvider).saveBatch(users, sync: useSync);
_stopwatch.stop();
logger.i('saveBatch/sync $useSync : ${_stopwatch.elapsedMilliseconds}ms');
return fetchAllUsers();
}
今回は書き込みが終わるまでオーバーレイでインジケータを表示しています。
/// 必要箇所のみ抜粋
/// ユーザーを追加するアクション
Future<void> createUserAction(
BuildContext context,
HomeViewModel viewModel,
ValueNotifier<List<User>> users,
) 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:
users.value = await viewModel.createAndFetchUser();
case CreateActionType.batchUseSync:
users.value = await viewModel.createBatchAndFetchUser(
useSync: true,
number: number,
);
case CreateActionType.batchUseAsync:
users.value = await viewModel.createBatchAndFetchUser(
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, 'エラーが発生しました');
}
}
enum CreateActionType {
single,
batchUseSync,
batchUseAsync,
}
4-2. 結果
同期処理の場合、メインスレッドで実行される分処理速度は早くなります。
💡 saveBatch/sync true : 882ms
逆に非同期処理はバックグラウンドで実行される分、処理速度は遅くなります。
💡 saveBatch/sync false : 1383ms
ただし、このメインスレッドはUIも構築している部分なのでUIがかくつく現象も発生します。
GIFではわかりづらいので今回は掲載しませんが、同期処理をしている場合はインジケータがうまく回っていませんでした。
逆に非同期処理の場合は処理が終わるまでインジケータが回り、しっかりとUIが描画されていました。
結論、よほどの理由がない限りは非同期の処理を使うというのがUIとしても良さそうです。
ぜひ、お手元のエミュレータで動かして試してみてください。
とはいえ、一瞬しかインジケータは出てこないので違いはちょっとわかりづらいですが😇
終わりに
今回の記事では、IsarにおけるEnumや埋め込みオブジェクトの保存方法や、同期・非同期処理の違いを解説しました。
同期処理は速度面で優れているもののUIに影響を与えるリスクがあり、実際のアプリケーションでは非同期処理を推奨されるケースが多いことを確認できました。
今後もさらにIsarを深堀りし、複雑なデータ操作やパフォーマンスチューニングに挑戦していきたいと思います。
ぜひ皆さんも、自分のプロジェクトでIsarを試してみてください。
何か疑問点や改善点がありましたら、コメントやフィードバックをいただけると幸いです。
最後までお読みいただき、ありがとうございました!