riverpod+hooks+HiveでFlutterアプリを作ったときメモ


先日、ポケモンWordleみたいなものをFlutterで作成してワードクイズ アプリとしてオープンソースにしました。
初めてriverpodに触ったため、色々と壁にぶち当たりました。せっかくなのでメモがてら残しておきます。
つまづきながらなので、真の解決策は別にあるかもしれないですが、その点はご了承ください。

riverpod+hooksの良さがわかった

親子関係を気にしたりする必要がないので使いやすかったです。
特にhooksでドロップダウンにuseState()をぱっと使えたりと楽でした。

// ここで定義
final dropdownValue = useState<QuizRange>(defaultQuizRange);
// こんな感じで使う
return DropdownButton<QuizRange>(
      value: dropdownValue.value,
      items: [
        ...quizRangeList.map(
          (e) => DropdownMenuItem(
            value: e,
            child: Text(e.displayName ?? ''),
          ),
        ),
      ],
      onChanged: (value) {
        dropdownValue.value = value!;
      },
    );

参考:quiz_selection_view.dart

useFutureを使ったときに何回もbuildが呼ばれる

useMemoizedと一緒に使いましょう。

https://github.com/rrousselGit/flutter_hooks/issues/119
final randomPickFuture = useMemoized<Future<Monster?>>(
      () => ref.watch(monsterPickerProvider).pick(),
);
final snapshot = useFuture(randomPickFuture);
if (!snapshot.hasData) {
  return const SizedBox.shrink();
}

参考:quiz_selection_view.dart

ref.watch(hoge)のテスト

Widgetで、ref.watch(hoge) されている場合、mockitoで出力したMockクラスを使おうとすると、addListenerのスタブがないよということでエラーが発生しました。まさにこの事象です。

https://zenn.dev/kskdev/articles/c90d541fbeca86

Widget->providerまでテストする場合は問題にはなりませんが、Widget単体でテストしたい場合は困ります。
その場合は、これで解決できました。

https://github.com/rrousselGit/river_pod/issues/273

実際にはこんな感じ。(Mockでは名前が衝突するのでFakeにしています)

class FakeExampleStateNotifier extends StateNotifier<bool> implements ExampleStateNotifier {
  FakeExampleStateNotifier(bool state) : super(state);
}

こんな感じで書く

ProvideScope(
    overrides:[
        exampleStateNotifierProvider.overrideWithValue(FakeExampleStateNotifier(false)),
    ],
)

参考:fake_quiz_info_notifier.dart

showDialog内でWidgetRef使うときはProviderScopeが必要

ダイアログ表示時の引数にWidgetRefを渡していましたが、たまにエラーが吐かれていました。
このようにProviderScopeで囲むと大丈夫になるようです。

https://stackoverflow.com/questions/67530584/access-providers-from-dialogs-for-flutter-hooks

とはいえ今回は、ダイアログで「はい」か「いいえ」のどちらがタップされたかが分かればいいので、呼び出し元の方で処理するようにしました。
そのため、showDialogはbool値だけを返すようにし、そもそもWidgetRefを渡さないように修正しました。

2.0.0では少し触れられている?

https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction/viewer/migrate-to-v2#2.0.0-dev.4

libフォルダ全体のカバレッジが出ない

どうやら flutter test --coverage では、テストを書いたファイルしかカバレッジ出力の対象にならないみたいで、全体のカバレッジが計測できません。

https://qiita.com/gki/items/a861d7ac1c61d6de928e
これを.shにとりこみました。
https://github.com/priezz/dart_full_coverage/blob/master/dart-coverage-helper

参考:coverage.sh

一部のLine Coverageが無視できない

Widgetのconstとなっているコンストラクタで super(key:key) の呼び出し部分がCoverageとしてはマークされないようです。

https://stackoverflow.com/questions/69266797/flutter-widget-tests-why-my-line-coverage-not-reflected-for-super-constructo

そのため、このような形で coverage:ignore:line をつけることにしました。

class StatisticsButton extends ConsumerWidget {
   const StatisticsButton({
     Key? key,
   }) : super(key: key); // coverage:ignore-line
...
}

しかし一部のファイルはこの状態では無視できませんでした。結局原因は不明だったのですが、1行に収めると解決しました。

class WordKeyboard extends ConsumerStatefulWidget {
   const WordKeyboard({Key? key}) : super(key: key); // coverage:ignore-line
      ...
}

Hiveはどうやってテストする?

これを使うといいと思います。

https://github.com/netsells/hive_test

今回はこれを見つける前に同じようなことをしていたので、独自に実装しました。

/// テスト用のパス
final _dirBasePath = '${Directory.current.path}/.dart_tool/test';

/// Hiveのテスト用の初期化を行います。
void setUpHive() {
  // タイミングによって衝突が発生するのでランダムな名称のフォルダとする
  final dirForTest = Directory('$_dirBasePath/${Random().nextInt(1000000000)}');
  // ディレクトリを一度削除して作成
  if (dirForTest.existsSync()) {
    dirForTest.deleteSync(recursive: true);
  }
  dirForTest.createSync(recursive: true);

  Hive.init(dirForTest.path);
}

/// Hiveのテスト用の削除を行います。
Future<void> tearDownHive() async {
  await Hive.deleteFromDisk();
}

参考:hive_tester.dart

Timerで更新するWidgetは分割する

ワードクイズでは、数百ミリ秒ごとに残り時間を更新する画面があります。
最初は画面全体buildメソッドの中に、時間表示Widgetを入れていましたが、時間更新のたびに頻繁にstateが更新されていました。そこでWidgetを分割して、画面全体が更新されないようにしました。
基本的な失敗なのですが、私自身への自戒を込めて残しておきます。

class _ClockText extends ConsumerWidget {
  const _ClockText({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    // remainingTimeProviderはTimerを使って頻繁に更新しています
    final remainingTime = ref.watch(remainingTimeProvider);
    return Text(
      remainingTime,
      style: const TextStyle(
        fontSize: 24,
        fontWeight: FontWeight.bold,
      ),
    );
  }
}

参考:statistics_view.dart

share_plusを使ってiOSでビルドしようとしたら、失敗する

ワードクイズにはシェア機能があります。
share_plusというパッケージを使っているのですが、iOSでビルドエラーが発生しました。
問題はこれで解決しました。

sudo arch -x86_64 gem install ffi
arch -x86_64 pod install

https://github.com/CocoaPods/CocoaPods/issues/10220#issuecomment-730963835

まとめ

初めてのことで困惑することが多かったですが、粘ればなんとか解決策は見つかりました。
テストに関しては難しいところも多かったですが、なんとか形になりました。