Flutterアプリをある程度作ってきて、モックアプリレベルのものを脱却すべく、データの永続化に挑戦しました。
最初はSQLiteを普通に入れようかと思っていたのですが、色々調べて試しているうちに泥沼にハマリ、MoorがAndroidのRoomを参考にしているということで(スペルも逆向きにしただけw)使ってみようとしたら、
「このアプリのデータって、RDBテーブル構成じゃ厳しくない?」
と気づいて、NoSQLにシフトしてもう一度探し直して・・・
すでにモデルクラスは作ってしまってある状態な為、SQLite系のORマッパーでは移植の道のりを思ってげんなりもしていたので、まあ気付いて良かったです。
で、良さそうだと本格的に着手したのがHiveです。
2系で互換性が無くなりそうですが、まだローンチされてないし、1系のサポートもしばらくはちゃんとやってくれると言うことだし、私の用途には十分そうだったので使うことにしました。
ListやMapに対応しているのも良いです。
enumも対応しているみたいですが、ひとまず今回は使いませんでした(使えそうなシチュエーションではあったのですが)。
環境など
ツールなど | バージョンなど |
---|---|
MacBook Air Early2015 | macOS Mojave 10.14.5 |
Android Studio | 3.6.1 |
Java | 1.8.0_131 |
Flutter | 1.12.13+hotfix.5 |
Dart | 2.7.0 |
Flutter SDKは、Mojaveで動かせる最終版を使用しているため、最新版ではありません。
Hiveの概要
1.特徴
- NoSQLである
- Key-Value
- メモリにキャッシュするので高速
- 100,000件くらいなら余裕らしい
2.使い方(簡易版)
- ドキュメントモデルの定義を作る
- アノテーションを使って項目名と型を宣言するだけ
- アダプタークラスを自動生成する
- generatorを走らせる必要がありますが、コマンドコピペして走らせるだけなので簡単
- Hiveを初期化
- 初期化メソッド呼んで上記で生成したアダプタークラスを追加するだけ
- データベース(Boxと言うようです)を開く
- 重複して開かない方法も用意されていて親切
- あとは書くなり読むなり自由にして
- closeは書かなくてもいいらしい
-
WatchBoxBuilder
でデータが更新された表示も自動更新- ただし、今回私は作成済みのデータモデルと別の構造で保存することにしたので、
ChangeNotifierProvider
+Selector/Consumer
パターンです。
- ただし、今回私は作成済みのデータモデルと別の構造で保存することにしたので、
こんな感じです。良さそうでしょ?
1つずつ、やってみましょう。
Hiveの設定と初期化
1.パッケージ依存関係
pubspec.yaml
に次のように追加します。dev
にも必要なので忘れずに。
dependencies:
flutter:
sdk: flutter
# DB
hive: ^1.4.1+1
hive_flutter: ^0.3.0+2
path_provider: ^1.6.7
dev_dependencies:
flutter_test:
sdk: flutter
# matcher
matcher: ^0.12.6
# mock
mockito: ^3.0.0
# for hive code generate
hive_generator: 0.7.0+2
build_runner: ^1.9.0
path_provider
とmockito
が最新版ではありません。それぞれ、別のパッケージが依存しているバージョンとコンフリクトしたからです。
アダプターコード自動生成のため、ジェネレーターとビルドランナーが必要です。
matcher
はお好みでいいですが、mockito
は後述するリポジトリクラス等のモック化のため必要です。
2. 初期化コード
main()
でアプリウィジェットを初期化する前にやりましょう。
void main() async {
setupLocator();
// Hiveの初期化
await Hive.initFlutter();
// TODO カスタムアダプターの追加
// 描画の開始
runApp(MyApp());
}
Hive.initFlutter()
がFutureを返すのでawait
させるため、main
関数をasync
としています。
カスタムアダプターはまだ作っていないので後で設定します。
サンプルだと、データベースのオープンまでやっているものが多いですが、初期化でファイル読み込み処理は重い気がするのでやっていません。
3.モデルとアダプターの作成
RecordModel
というモデルクラスを作るとします。
import 'package:hive/hive.dart';
part 'database.g.dart';
@HiveType(typeId: 1)
class RecordModel {
@HiveField(0)
String date;
@HiveField(1)
List<int> list;
@HiveField(2)
Map<String, String> map;
RecordModel(this.date, this.list, this.map);
}
/// Boxを内包するクラス
/// Singletonやboxを開くのを非同期で待つのに使う
/// Boxのファイル名を間違えないようにするためにもこれを起点にアクセスすることとする
/// また、DIに対応するときにもきっと役に立つ
class RecordModelBox {
Future<Box> box = Hive.openBox<RecordModel>('record');
/// deleteFromDiskをした後はdatabaseが閉じてしまうため、もう一度開くための関数
Future<void> open() async {
Box b = await box;
if (!b.isOpen) {
box = Hive.openBox<RecordModel>('record');
}
}
}
@HiveType(typeId: 1)
は、モデルクラス毎に一意のtypeId
を指定しています。
@HiveField(N)
で、個別の項目、いわゆるカラム的なものを宣言します。Nは手動でインクリメントしないと行けないのがちょっと手間です。
上記を踏まえて・・・
RecordModel
は、キーとして日付文字列(yyyy-MM-dd)、カラムとしてintのリスト(要素数不定)とmap(要素数不定)、という構成ということになります。
他のSQLite関連のパッケージだと、ListやMapはマッパーを書くのがすごく大変(そう)です。でもHiveならそのまま。ありがたい!
RecordModelBox
は、RecordModel
を保存したデータベースファイル(Boxファイル)を開くためのラッパークラスです。
あとでリポジトリクラスがBoxファイルにアクセスするのに使います。ぶっちゃけなくても良さそうですが、参考にしたサイト(末尾参照)でやっていて、DIとか考えると良さそうだったので同じにしました。
open
関数は、テスト用に全件削除するdeleteFromDisk
を呼んだ後、次のテストをしようとして例外が発生したので調べたら、データベースがcloseされてしまうようだったので、その後直ぐ再オープン出来るようにするために作りました。
必要があれば使って下さい。
import
文の下にあるpart
の部分は、今は赤字になりますが、コード生成を行うと解決されます。
4.コードをジェネレート
以下のコマンドでアダプタークラスを自動生成します。
$ flutter pub run build_runner build
そこそこ時間がかかるようです。トイレに行ったり水を飲んだり犬と遊んだりして待ちましょう。
database.dart
と同じ階層に、database.g.dart
というファイルが出来ます。
中身を見てみると、こんな感じかと思います。
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'database.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class RecordModelAdapter extends TypeAdapter<RecordModel> {
@override
final typeId = 1;
@override
RecordModel read(BinaryReader reader) {
var numOfFields = reader.readByte();
var fields = <int, dynamic>{
for (var i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return RecordModel(
fields[0] as String,
(fields[1] as List)?.cast<int>(),
(fields[2] as Map)?.cast<String, String>(),
);
}
@override
void write(BinaryWriter writer, RecordModel obj) {
writer
..writeByte(3)
..writeByte(0)
..write(obj.date)
..writeByte(1)
..write(obj.list)
..writeByte(2)
..write(obj.map);
}
}
作成されたファイルの先頭に書いてあるとおり、このファイルは編集してはいけません。
モデルクラスを変更したら、その度に生成コマンドを叩く必要があります。
そして、このコードをバージョン管理(git)などにも上げると思いますが、その後に生成コマンドを叩くと、ファイルが消せない!とエラーが出ます。
その場合は、エラーメッセージにも書いてありますが、生成コマンドに--delete-conflicting-outputs
オプションを付ければ良いようです。
5.アダプターをHiveに登録
先ほどTODO
コメントしてた所に、自動生成されたアダプタークラスを登録するコードを書きます。
// Hiveの初期化
await Hive.initFlutter();
// カスタムアダプターの追加
Hive.registerAdapter(RecordModelAdapter());
ここまでで、データベースの準備が出来ました。
SQLのCREATE文を書くよりずっと早いと思います(慣れてる人は違うのかも知れませんが)。
このモデルは要素も少なくて単純ですが、もっと複雑な構造だと威力を実感するのではないでしょうか?
リポジトリクラス
Android Arcitecture ComponentsリスペクトなMVVMな感じで作っているので、リポジトリクラスを作ります。
1. Boxへのリファレンス
直接Boxを持っても良いですが、前述の通り、アクセサクラスRecordModelBox
を使います。
class RecordRepository{
RecordModelBox _recordBox;
/// コンストラクタ
/// [recordModelBox] Boxへのアクセサクラス
RecordRepository(RecordModelBox recordModelBox) {
_recordBox = recordModelBox;
}
}
DIしやすいようにコンストラクタで受け取ります。
2. データの追加
まずは追加できないとお話にならないので追加から。
/// レコードの変更を保存
Future<void> save(RecordData record) async {
final box = await _recordBox.box;
await box.put(record.date, record.toBoxModel());
}
RecordData#toBoxModel
は、データベース化する前から作成していたデータクラスを、データベース用のモデルクラスに変換するための関数です。はっきり言ってこれは苦肉の策なので、こういうことが必要ないように、最初から使用するデータベースを考慮した設計をした方が良いです。
final box = await _recordBox.box;
の行ですが、ここで、実際にBoxファイルが開くのを待っています。待たないと「まだOpenされてないファイルに書き込もうとした」ということで、例外が発生します。
box.put
も非同期なので、Future<void>
なasync
関数になっています。
基本的にbox
アクセスは非同期です。どうやら普通にランダムファイルアクセスをしているようなので、まあ当然ですね。
データを追加するには、3つのパターンがあります。
-
put(key, value)
- ここで使っているKey-Valueで保存
-
add(value)
- Auto-incrementなindexをキーにして保存
-
putAt(index, value)
- indexを指定してそれをキーにして保存
このデータは日付がキーなので、1つ目の関数を使っています。
3. データの取得
データが登録できたら取ってみましょう。
/// 全件データを取得
Future<List<RecordData>> fetchAll() async {
final box = await _recordBox.box;
List<RecordModel> list = box.values.toList();
var recordList = List<RecordData>();
list?.forEach((model) => recordList.add(RecordData.fromBoxModel(model)));
return recordList;
}
/// 日付を指定してレコードを取得
/// return : nullable
Future<ProteinRecord> get(String date) async {
final box = await _recordBox.box;
final model = await box.get(date);
if (model == null) return null;
return ProteinRecord.fromBoxModel(model);
}
前述の通り、データクラスへの変換があるので分かりづらくなっていますが、通常なら、box.values.toList();
だけで済むはずです。
4. データの削除
データを1件削除するのと、全部削除するのと。
/// 全件削除
Future<void> deleteAll() async {
final Box box = await _recordBox.box;
await box.deleteFromDisk();
await _recordBox.open(); // もう一度開く
}
/// 1件削除
Future<void> delete(String date) async {
final Box box = await _recordBox.box;
await box.delete(date);
}
先ほども書いたとおり、deleteFromDisk
した後はBoxファイルがcloseされてしまっているため、その後Boxファイルにアクセスしようとするとエラーになります。それを避ける為、直後にopen
させています。
1件削除するdelete
ですが、実際にはレコードは消えないそうです。削除されたという状態が書き込まれるそうです。論理削除ですね。
物理削除は全く出来ないわけでは無く、なにやら細かい制御をしているみたいで、あるタイミングでそれらを実際にごっそり削除してくれているそうです。
通常は気にしなくて良さそうですが、自分でcompaction
というのを呼んでそれを手動で実施させることも出来るとのことです。
https://docs.hivedb.dev/#/advanced/compaction
5.条件付き検索
残念ながら、Hiveにはいわゆるクエリー用の関数はありません。それは、Dartの言語仕様でやったほうが早いから、だそうです(https://docs.hivedb.dev/#/best-practices/modelling_data?id=modelling-data)
ということで、以下のような検索が出来る関数を作ってみました。
- ある日付以降からある日付未満の
date
を持つデータを抽出する
こうなりました。
/// 月間データを取得
/// [startDate] 検索開始日付を表す文字列(yyyy-MM-dd)
/// [endDate] 検索終了日付を表す文字列(yyyy-MM-dd)(この日付を含まない)
Future<List<RecordData>> searchRange(
{@required String startDate, @required String endDate}) async {
// formatチェック
try {
DateTime.parse(startDate);
DateTime.parse(endDate);
} on FormatException catch (e) {
debugPrint(
"日付フォーマットが間違っています。yyyy-MM-ddで指定して下さい。[$startDate or $endDate]");
throw e;
}
var recordList = List<RecordData>();
try {
final box = await _recordBox.box;
List<RecordModel> list = box.values
.where((n) =>
n.date.compareTo(startDate) >= 0 && n.date.compareTo(endDate) < 0)
.toList();
list?.sort((a, b) => a.date.compareTo(b.date)); // 必要ないかも?
list?.forEach(
(model) => recordList.add(RecordData.fromBoxModel(model)));
} catch (e) {
debugPrint("データの取得で例外発生しました。 $e.toString()");
}
return recordList;
}
box.values
で全件を取得し、そのリストでまずwhere
で条件に合致するものだけを抽出しています。
その後、得られたリストをソートして返しています。
なお、コメントにもあるように、もしかしたらソートは不要かもです。キーでソートされてる、と書いてあったので。
https://docs.hivedb.dev/#/best-practices/modelling_data?id=key-order
データが無いときに落ちないように、?
を使っています。
Providerパターンでリポジトリクラスにアクセス
リポジトリクラスはアプリ全体で1個インスタンスがあれば良いので、シングルトンも考えましたが、ちょうどBLoC+Providerパターンを書いたところだったので、またこれを使おうと思いました。
アプリのトップでプロバイダーを宣言しておけばもう、いつでもどこからでも取り出せますからね。
void main() async {
setupLocator();
// Hiveの初期化
await Hive.initFlutter();
Hive.registerAdapter(RecordModelAdapter());
// 描画の開始
runApp(Provider<RecordRepository>(
create: (context) => RecordRepository(RecordModelBox()),
dispose: (context, bloc) => bloc.dispose(),
child: MyApp(),
));
}
こうしておけば、アプリ内のどのウィジェットからも取れるし、他のBlocやViewModel(的に使っているクラス)に渡すことも簡単になります。
以下は、リポジトリクラスに依存するTopViewModel
クラスに、リポジトリクラスをProvider
から取得してコンストラクタインジェクションで渡している例です。
class TopPage extends StatelessWidget {
TopPage(
{Key key, this.date})
: super(key: key);
final String date;
@override
Widget build(BuildContext context) {
final repository = Provider.of<RecordRepository>(context, listen: false);
return ChangeNotifierProvider<TopViewModel>(
create: (context) =>
TopViewModel.withDisplayDate(date, repository: repository),
child: _buildPage(context),
);
}
...
}
テストの時にも、DIで置き換えやすくなります。大本でモック化したものをセットすれば、子ウィジェットやロジッククラスには自動的に全部モック化したものが伝搬しますから。
テスト
ユニットテストを書きます。
今まで結構避けてきたんですけど、仕方なく(?)、Mockitoをとうとう使うことにしました。
リポジトリクラスをモック化しないとテストでデータを書き込んでは削除して、となってしまうのを避ける為です。
先ほども書きましたが、DIを意識しておくとテストを書くスピードが格段に上がります。
1.リポジトリクラスのテスト
これは、実際に書込や読込が合っているか確認が必要なので、実際にファイルに書き込みを行います。従ってモック化をしません。
(1)初期化
以下のような初期化メソッドを作ります。
/// Hiveの初期化
Future<void> initialiseHive() async {
final path = Directory.current.path;
Hive
..init(path)
..registerAdapter(RecordModelAdapter());
Hive.deleteFromDisk(); // 常に空の状態で開始する
}
Hive..init(path)
で、パスを指定していますが、本アプリの方は指定していませんでした。
テスト用には指定しないと上手く動かなかったのですが、これは実際のアプリ実行時にはアプリのローカルファイル領域にアクセスできるけど、テスト時にはアプリのコンテキストは無い状態なのでその領域へのパスも取れず、、、ということみたいです。
Hive.deleteFromDisk()
は実際のBoxファイルを物理的に空にする関数です。(ファイル自体は0byteで残るみたい)
これを、テスト関数の最初で呼べば良いですね。
/// RecordRepositoryクラスのテスト
void main() async {
await initialiseHive();
}
(2)read/write
1件追加して、全件取得する、というテストでReadとWriteがきちんと出来ているか確認します。
/// RecordRepositoryクラスのテスト
void main() async {
await initialiseHive();
group("RecordRepositoryクラスのテスト", () {
RecordRepository repository;
setUp(() {
repository = RecordRepository(RecordModelBox());
});
tearDown(() async {
// 最後に必ずクリア
await Hive.deleteFromDisk();
});
test('save/fetchAll', () async {
final points = [1,2,3,4,5];
final map = {'key':'value'};
final record = RecordData("2020-02-20", list: points, map:map);
repository.save(record);
final list = await repository.fetchAll();
expect(list, [record]);
});
test('get', () async {
final points = [1,2,3,4,5];
final map = {'key':'value'};
final record = RecordData("2020-02-20", list: points, map:map);
repository.save(record);
final item = await repository.get("2020-02-20");
expect(item, record);
});
});
}
save/fetchAll
のテストは、save
後にfetchAll
を呼んで、データが1件あるはず、ということをチェックしています。
get
はもう分かりますね。
それとtearDown
でテストメソッド1個終了するたびにやはりBoxファイルをクリアしています。
(3)その他のテスト
read/writeのテストが書ければ、もうそんなに難しくはないですが、一応載せておきます。
test('delete', () async {
final points = [1,2,3,4,5];
final map = {'key':'value'};
final record = RecordData("2020-02-20", list: points, map:map);
await repository.save(record);
await repository.deleteAll();
final list = await repository.fetchAll();
expect(list, []);
final item = await repository.get("2020-02-20");
expect(item, isNull);
});
test('update', () async {
final points = [1,2,3,4,5];
final map = {'key':'value'};
final record = RecordData("2020-02-20", list: points, map:map);
await repository.save(record);
final points2 = [12,2,3,4,25];
final map2 = {'key2', 'value2'};
final record2 = RecordData("2020-02-20", list: points, map:map);
await repository.save(record2);
final list = await repository.fetchAll();
expect(list, [record2]);
});
test('onedelete', () async {
final points = [1,2,3,4,5];
final map = {'key':'value'};
final record = RecordData("2020-02-20", list: points, map:map);
await repository.save(record);
final points2 = [12,2,3,4,25];
final map2 = {'key2', 'value2'};
final record2 = RecordData("2020-02-20", list: points, map:map);
await repository.save(record2);
await repository.delete(record.date);
final list = await repository.fetchAll();
expect(list, [record2]);
});
test('searchRangeのFormat例外スロー:startDate', () async {
expect(() =>
repository.searchRange(
startDate: "2020/02/01", endDate: "2020-02-29"),
throwsFormatException);
});
test('searchRangeのFormat例外スロー:endDate', () async {
expect(() =>
repository.searchRange(
startDate: "2020-02-01", endDate: "2020/02/29"),
throwsFormatException);
});
test('searchRange', () async {
final logs = [
// @formatter:off
RecordData.from("2019-12-28", [1,2], {'k':'v'}),
RecordData.from("2019-12-29", [1,2], {'k':'vv'}),
RecordData.from("2019-12-30", [1,2], {'k':'v'}),
RecordData.from("2020-01-01", [1,2,3], {'kk':'v'}),
RecordData.from("2020-01-02", [1,2], {'k':'v'}),
RecordData.from("2020-01-03", [2,2], {'s':'v'}),
RecordData.from("2020-01-08", [1,2], {'k':'v'}),
RecordData.from("2020-01-10", [3,2], {'k':'m'}),
RecordData.from("2020-01-21", [1,2], {'k':'v'}),
RecordData.from("2020-01-22", [3,5], {'k':'vvv', 'o':'p'}),
RecordData.from("2020-01-23", [1,2], {'k':'v'}),
RecordData.from("2020-01-24", [1,2], {'k':'v'}),
RecordData.from("2020-01-31", [1,2], {'kd':'vd'}),
RecordData.from("2020-02-08", [1,2], {'ko':'vo'}),
RecordData.from("2020-02-09", [3,2], {'kk':'vk'}),
// @formatter:on
];
await repository.addAll(logs);
final all = await repository.fetchAll();
expect(all.length, logs.length);
final list = await repository.searchRange(
startDate: '2019-12-29', endDate: '2020-02-09');
expect(list.length, 13);
});
});
2.リポジトリクラスをモック化してテスト
ロジックの単体テストやウィジェットのテストなどでは、リポジトリクラスはモック化して、実際にはデータの書き込みは行わないようにします。
※後で判明しますが、モック化はしなくても大丈夫です。余談の項目参照。
(1)リポジトリクラスをモック化する
モック化には、Mockitoを使います。
モック化したクラスをどこかに作っておきましょう。
/// Mock RecordRepository
class MockRecordRepository extends Mock implements RecordRepository {}
Dartの場合、暗黙的インターフェースという機能のお陰で、implements RecordRepository
としておくことで、すべてのメソッドが自動的に未実装な状態になります。それを、何もしない関数にextends Mock
でやってくれる、と考えておけばいいように思います。
(2)ロジッククラスのテスト
テストの一部を載せておきます。
void main() {
// リポジトリクラスをモック化
final mockRepository = MockRecordRepository();
// ただモック化しただけだと、searchRangeがnullを返してしまうが、本来は有り得ないため、
// 空リストを返すように上書き
when(mockRepository.searchRange(
startDate: anyNamed('startDate'), endDate: anyNamed('endDate')))
.thenAnswer((_) => Future.value([]));
group("MainViewModelクラスのテスト", () {
MainViewModel viewModel;
setUp(() {
viewModel = MainViewModel(
repository: mockRepository);
});
test('createCalendarData(set cellData)', () async {
viewModel.setDisplayDate("2019-06-20");
await viewModel.createCalendarData();
expect(viewModel.cellData, hasLength(42));
verify(mockRepository.searchRange(
startDate: "2019-05-26", endDate: "2019-07-07"));
});
});
かいつまんで説明します。
final mockRepository = MockRecordRepository();
ここは問題ないですよね。モック化されたリポジトリクラスをインスタンス化しています。
when(mockRepository.searchRange(
startDate: anyNamed('startDate'), endDate: anyNamed('endDate')))
.thenAnswer((_) => Future.value([]));
searchRange
関数を通るテストで、nullが本来は返らないように実装してあるものの、モック化されていることで、nullが返ってしまいます。
そこで空のリストを返すように変えています。
setUp(() {
viewModel = MainViewModel(
repository: mockRepository);
});
テストの初期化処理として、ロジッククラスにモック化リポジトリクラスをコンストラクタインジェクションで渡しています。
verify(mockRepository.searchRange(
startDate: "2019-05-26", endDate: "2019-07-07"));
リポジトリクラスのsearchRange
が、指定の引数で呼ばれたことをチェックしています。
引数のチェックまで不要な場合には、次のように書けます。
verify(mockRepository.searchRange(
startDate: anyNamed("startDate"), endDate: anyNamed("endDate")));
(3)ウィジェットのテスト
リポジトリクラスはProviderでアプリのトップから伝搬するようにしたので、ウィジェットテストでもそうなるようにしておく必要があります。
ウィジェットクラスのテストに、こんなのを作っていました。本体アプリと同等の設定をした上で、テストするWidgetを直接起動するようなイメージです。
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class TestApp extends StatelessWidget {
TestApp(this.widget);
final Widget widget;
@override
Widget build(BuildContext context) {
return MaterialApp(
// 日本語フォント設定
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: [
Locale('ja', ''), // Japanese
],
home: widget,
);
}
}
これのルートを、Provider
にすれば良いですね。
@override
Widget build(BuildContext context) {
return Provider<RecordRepository>(
create: (context) => MockRecordRepository(),
dispose: (context, bloc) => bloc.dispose(),
child: MaterialApp(
// 日本語フォント設定
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: [
Locale('ja', ''), // Japanese
],
home: widget,
),
);
}
}
あとはこのクラスを使って、ウィジェットテストの際にpumpWidget(TestApp(SubWidget()));
のようにすれば、どのウィジェットもモック化されたリポジトリクラスを使ってくれます。
余談
テストを全部通し、最終的なコードをコミットしようとして気付いたんですが、プロジェクト直下にこのようなファイルが出来ていました。
多分、犯人はこれです。
/// Hiveの初期化
Future<void> initialiseHive() async {
final path = Directory.current.path; // <-ここがプロジェクトフォルダになる
Hive
..init(path)
..registerAdapter(RecordModelAdapter());
Hive.deleteFromDisk(); // 常に空の状態で開始する
}
つまり、UnitTestの時に作成されたファイルのようです。
テスト後にはデータ削除しているので0byteです。でもファイルは消してくれません。
つまりコミットするときにUncomitted Changes
に上がってしまいます。
無視ファイルに入れましょう。
*.hive
*.lock
!pubspec.lock
pubspec.lock
は、Dartの説明によると、アプリケーションの場合はコミットすべき、ということなので、例外指定を入れています。
https://dart.dev/guides/libraries/private-files
え・・・もしかして、モック化必要なかった!?(笑)
ま、まあ、今までずっと避けてきてたMockito勉強できたってことで(汗)
それに、テスト毎にデータクリアしたりも面倒だし・・・ね?
感想
モックアプリ作成から入ったのでモデル定義のところでマッピングが必要な状態になってしまい、WatchBoxBuilder
が使えませんでしたが、それでもSQLiteを書いていくよりはずっと楽に入れられたと思います。
データベースを何にするかを決めてからやっていたら(あるいは情報を多少集めてから設計しておいたら)、もっと楽に出来たと思います。
WatchBoxBuilder
使えたら、AndroidでいうRoomがLiveData返してくれるからDatabindingしてたら勝手に表示更新してくれる、というのと近いことが出来たのになあ。
ページ遷移から戻ってきて自前でnotifyListeners();
呼んで画面更新しなきゃ、とかしなくて済んだのになあ・・・(汗)
設計(と事前の情報収集)は大事!!
参考サイト
色んなパッケージの比較の参考になりました。
https://kabochapo.hateblo.jp/entry/2020/02/01/144411
公式のドキュメント
https://docs.hivedb.dev/#/
公式のモデリングに関するドキュメント。ソートやフィルタリングについて書いてあります。
Dart言語の機能でやった方が早いよと言っています。
https://github.com/hivedb/docs/blob/master/best-practices/modelling_data.md
エラーの解決
https://github.com/hivedb/hive/issues/239
リポジトリクラス、そのテストの参考になりました。
https://techpotatoes.com/2020/04/06/flutter-development-series-part-3-using-databases-in-flutter/
WatchBoxBuilderの使い方が参考になります。
https://medium.com/@viveky259259/hive-for-flutter-3-implementation-e825a0a833d3
Mockitoの書き方の参考になりました。
https://qiita.com/ko2ic/items/78c4a035a0aef9cfc78