56
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutterでお手軽にNoSQLでデータ保存

Last updated at Posted at 2020-04-27

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にも必要なので忘れずに。

pubspec.yaml
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_providermockitoが最新版ではありません。それぞれ、別のパッケージが依存しているバージョンとコンフリクトしたからです。
アダプターコード自動生成のため、ジェネレーターとビルドランナーが必要です。

matcherはお好みでいいですが、mockitoは後述するリポジトリクラス等のモック化のため必要です。

2. 初期化コード

main()でアプリウィジェットを初期化する前にやりましょう。

main.dart
void main() async {
  setupLocator();

  // Hiveの初期化
  await Hive.initFlutter();
  // TODO カスタムアダプターの追加

  // 描画の開始
  runApp(MyApp());
}

Hive.initFlutter()がFutureを返すのでawaitさせるため、main関数をasyncとしています。
カスタムアダプターはまだ作っていないので後で設定します。
サンプルだと、データベースのオープンまでやっているものが多いですが、初期化でファイル読み込み処理は重い気がするのでやっていません。

3.モデルとアダプターの作成

RecordModelというモデルクラスを作るとします。

database.dart
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というファイルが出来ます。

中身を見てみると、こんな感じかと思います。

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コメントしてた所に、自動生成されたアダプタークラスを登録するコードを書きます。

main.dart
  // Hiveの初期化
  await Hive.initFlutter();
  // カスタムアダプターの追加
  Hive.registerAdapter(RecordModelAdapter());

ここまでで、データベースの準備が出来ました。
SQLのCREATE文を書くよりずっと早いと思います(慣れてる人は違うのかも知れませんが)。

このモデルは要素も少なくて単純ですが、もっと複雑な構造だと威力を実感するのではないでしょうか?

リポジトリクラス

Android Arcitecture ComponentsリスペクトなMVVMな感じで作っているので、リポジトリクラスを作ります。

1. Boxへのリファレンス

直接Boxを持っても良いですが、前述の通り、アクセサクラスRecordModelBoxを使います。

RecordRepository.dart
class RecordRepository{
  RecordModelBox _recordBox;

  /// コンストラクタ
  /// [recordModelBox] Boxへのアクセサクラス
  RecordRepository(RecordModelBox recordModelBox) {
    _recordBox = recordModelBox;
  }
}

DIしやすいようにコンストラクタで受け取ります。

2. データの追加

まずは追加できないとお話にならないので追加から。

RecordRepository.dart
  /// レコードの変更を保存
  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. データの取得

データが登録できたら取ってみましょう。

RecordRepository.dart
  /// 全件データを取得
  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件削除するのと、全部削除するのと。

RecordRepository.dart
  /// 全件削除
  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を持つデータを抽出する

こうなりました。

RecordRepository.dart
  /// 月間データを取得
  /// [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パターンを書いたところだったので、またこれを使おうと思いました。
アプリのトップでプロバイダーを宣言しておけばもう、いつでもどこからでも取り出せますからね。

main.dart
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から取得してコンストラクタインジェクションで渡している例です。

top_page.dart
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)初期化

以下のような初期化メソッドを作ります。

RecordRepository_test.dart
/// 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_test.dart
/// RecordRepositoryクラスのテスト
void main() async {
  await initialiseHive();
}

(2)read/write

1件追加して、全件取得する、というテストでReadとWriteがきちんと出来ているか確認します。

RecordRepository_test.dart
/// 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のテストが書ければ、もうそんなに難しくはないですが、一応載せておきます。

RecordRepository_test.dart
    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を使います。
モック化したクラスをどこかに作っておきましょう。

test/dummys.dart
/// Mock RecordRepository
class MockRecordRepository extends Mock implements RecordRepository {}

Dartの場合、暗黙的インターフェースという機能のお陰で、implements RecordRepositoryとしておくことで、すべてのメソッドが自動的に未実装な状態になります。それを、何もしない関数にextends Mockでやってくれる、と考えておけばいいように思います。

(2)ロジッククラスのテスト

テストの一部を載せておきます。

MainViewModel_test.dart
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_test_file.png

多分、犯人はこれです。

/// Hiveの初期化
Future<void> initialiseHive() async {
  final path = Directory.current.path;  // <-ここがプロジェクトフォルダになる
  Hive
    ..init(path)
    ..registerAdapter(RecordModelAdapter());

  Hive.deleteFromDisk(); // 常に空の状態で開始する
}

つまり、UnitTestの時に作成されたファイルのようです。

テスト後にはデータ削除しているので0byteです。でもファイルは消してくれません。
つまりコミットするときにUncomitted Changesに上がってしまいます。
無視ファイルに入れましょう。

.gitignore
*.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

56
37
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?