おみくじアプリを作りました

2024/12/23に公開

はじめに

Flutter Web でおみくじアプリを作りました!大吉が出ると花吹雪が舞い、たまにおみくじが出てこないエラーが発生するようにしました。ぜひお試しください!

https://susatthi.github.io/flutter-omikuji/

以下の minn さんの記事にインスパイアされておみくじアプリを作りました!minn さんありがとうございます!

https://zenn.dev/minn/articles/adocale-2024

本記事では、おみくじアプリを題材に、「非同期の操作」を制御しない場合の問題点を挙げつつ、「非同期の操作」を制御すべき理由と、Riverpod を使って制御する方法 を紹介します。

環境

  • Flutter 3.27.1
  • flutter_riverpod 2.6.1
  • riverpod_generator 2.6.3

おみくじアプリの要件

最初に、おみくじアプリの要件を抑えておきましょう。「おみくじを引く」という操作が非同期であることがポイントです。

  • ボタンがタップされたらランダムでおみくじを引くこと
    • おみくじの結果を出すまで 3 秒間待つこと
    • 1/5の確率でおみくじが出てこないエラーを投げること
  • 引いたおみくじをダイアログで表示すること

「非同期の操作」を制御しない実装

「非同期の操作」を制御しないときの実装イメージは次のとおりです。

まず「おみくじの結果」を enum で作ります。

おみくじの結果
enum Omikuji {
  daikichi,
  kichi,
  chuukichi,
  syoukichi,
  suekichi,
  kyou,
  daikyou,
  ;
}

「おみくじの結果」は複数の Widget から参照されるため、Riverpod の Provider を使ってグローバルな状態として管理します。また、「おみくじを引く」メソッドを定義したいので、Classed Provider で実装します。

おみくじ Notifier

class OmikujiNotifier extends _$OmikujiNotifier {
  
  Omikuji? build() => null;

  /// おみくじを引く。
  Future<void> draw() async {
    // 3秒間遅延
    await Future<void>.delayed(const Duration(seconds: 3));

    // 1/5の確率でエラーになる
    if (math.Random().nextInt(5) == 0) {
      throw Exception('おみくじが出てきませんでした。もう一度引いてください。');
    }

    // ランダムでおみくじを引く
    state = Omikuji.values[math.Random().nextInt(Omikuji.values.length)];
  }
}

おみくじを引くボタンを実装します。ボタンがタップされたら先ほど実装した「おみくじを引く」メソッドを呼び出しています。

おみくじを引くボタン
class DrawOmikujiButton extends ConsumerWidget {
  const DrawOmikujiButton({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return TextButton(
      onPressed: () => ref.read(omikujiNotifierProvider.notifier).draw(),
      child: const Text('おみくじを引く'),
    );
  }
}

最後に、おみくじの結果をダイアログで表示します。ref.listen で「おみくじの結果」を監視してダイアログを表示します。

おみくじの結果をダイアログで表示する
ref.listen(
  omikujiNotifierProvider,
  (_, omikuji) async {
    // おみくじが引かれていない場合は何もしない
    if (omikuji == null) {
      return;
    }

    // おみくじの結果を表示
    await showDialog<void>(
      context: context,
      builder: (context) => _OmikujiResultDialog(omikuji: omikuji),
    );
  },
);

これで「おみくじを引く」という非同期のイベント処理が実装できました!

問題点

正常系はちゃんと動くのでパッと見よさそうですが、「非同期の操作」を制御していないこの実装には次の問題があります。

  • ボタンを連打すると、ダイアログが連続で表示されてしまう(準正常系)
  • エラーを捕捉できていない(異常系)

これらの問題を解決するために、Riverpod を使って「非同期の操作」を制御する実装を紹介します。

「非同期の操作」を制御する実装

「非同期の操作」を制御したときの実装イメージは次のとおりです。ボタンタップと「おみくじを引く」メソッドの間で、「おみくじを引く」という 「非同期の操作」の状態を管理することで制御します。

おみくじを引くという「非同期の操作」を Riverpod の Provider を使ってグローバルな状態として管理します。呼び出し用のメソッドを 1 つだけ定義したいので、Classed Provider で実装します。

「操作」を表すクラスなので、Notifier のクラス名は 動詞 + UseCase という命名規則とし、メソッド名は「呼び出す」という意味の invoke() としました。また、状態は何も持たないので void 型とします。

おみくじを引くボタン

class DrawOmikujiUseCase extends _$DrawOmikujiUseCase {
  
  FutureOr<void> build() => null;

  Future<void> invoke() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await ref.read(omikujiNotifierProvider.notifier).draw();
    });
  }
}

呼び出し用のメソッドでは、最初に「非同期の操作」の状態を loading にして、おみくじを引く処理が完了したら「非同期の操作」の状態を data(void) にしています。ちなみに AsyncValue.guard() は、非同期処理がエラーになった場合に「非同期の操作」の状態を error にしてくれるメソッドです。

「非同期の操作」の状態遷移を図示すると次のようになります。

次はおみくじを引くボタンを修正していきます。
ボタンタップの処理は、DrawOmikujiUseCase クラスの invoke() メソッドを呼び出すように変更します。

おみくじを引くボタン
class DrawOmikujiButton extends ConsumerWidget {
  const DrawOmikujiButton({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return TextButton(
+     onPressed: () => ref.read(drawOmikujiUseCaseProvider.notifier).invoke(),
-     onPressed: () => ref.read(omikujiNotifierProvider.notifier).draw(),
      child: const Text('おみくじを引く'),
    );
  }
}

ボタンを連打することによる多重実行の抑止は、「非同期の操作」の状態を ref.watch で監視して、その状態が loading の場合にボタンを押せないようにすることで実現できます。

おみくじを引くボタン
class DrawOmikujiButton extends ConsumerWidget {
  const DrawOmikujiButton({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
+   final isLoading = ref.watch(drawOmikujiUseCaseProvider).isLoading;
    return TextButton(
-     onPressed: () => ref.read(drawOmikujiUseCaseProvider.notifier).invoke(),
+     onPressed: isLoading
+         ? null
+         : () => ref.read(drawOmikujiUseCaseProvider.notifier).invoke(),
      child: const Text('おみくじを引く'),
    );
  }
}

エラーハンドリングは、「非同期の操作」の状態を ref.listen で監視することで実装できます。エラー時だけでなく、処理中や処理完了への状態変化時の処理も同時に実装できるため、コードの見通しがよくなります。

おみくじを引くボタン
class DrawOmikujiButton extends ConsumerWidget {
  const DrawOmikujiButton({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
+   ref.listen(
+     drawOmikujiUseCaseProvider,
+     (_, next) => next.when(
+       loading: () {},
+       data: (_) {},
+       error: (err, _) {
+         ScaffoldMessenger.of(context).showSnackBar(
+           SnackBar(content: Text(err.toString())),
+         );
+       },
+     ),
+   );
    final isLoading = ref.watch(drawOmikujiUseCaseProvider).isLoading;
    return TextButton(
      onPressed: isLoading
          ? null
          : () => ref.read(drawOmikujiUseCaseProvider.notifier).invoke(),
      child: const Text('おみくじを引く'),
    );
  }
}

おみくじアプリでは、おみくじを引いている間、実際に引いているかのように見せるためにおみくじの箱を上下に揺らしています。これは、ref.listenloading 時にボタンを上下に揺らし、data(void) 時に停止することで実現しています。

また、毎回 ref.listen の中身を書くのは面倒なので、私はよく ref.listenAsync という拡張メソッドを作って利用しています。

ref.listenAsync
extension WidgetRefX on WidgetRef {
  /// listen()のAsyncValue版
  void listenAsync<T>(
    ProviderListenable<AsyncValue<T>> provider, {
    void Function(T data)? success,
    void Function()? loading,
  }) =>
      listen<AsyncValue<T>>(
        provider,
        (_, next) => next.when(
          data: (data) {
            success?.call(data);
          },
          error: (err, _) => showDialog<void>(
            context: context,
            builder: (context) => ErrorDialog(error: err),
          ),
          loading: () {
            loading?.call();
          },
        ),
        onError: (err, _) => showDialog<void>(
          context: context,
          builder: (context) => ErrorDialog(error: err),
        ),
      );
}

// 使い方
ref.listenAsync(drawOmikujiUseCaseProvider);

これで、「非同期の操作」を制御しないときの問題点が解決できました 🎉

おみくじアプリのコードは公開しています

上記で紹介した「非同期の操作」を制御する実装をしたおみくじアプリのコードを公開しています。

https://github.com/susatthi/flutter-omikuji

「非同期の操作」という状態に着目してみよう

Flutter において状態管理はとても重要です。では、その「状態」とは何でしょうか?

すぐに思いつくのは「実体のあるもの=オブジェクト」です。おみくじアプリにおいては「おみくじの結果」にあたります。「おみくじの結果」という「状態」が変化したらダイアログを表示する、というような実装は容易にイメージできます。

本記事でお伝えしたいのは、「オブジェクト」以外にも「操作」という状態もある ということです。特に「非同期の操作」は、「処理中」「処理完了」「エラー」といった状態の変化があります。

「おみくじを引く」という 「非同期の操作」の状態を適切に制御することで、

  • エラーのハンドリングができる
  • 多重実行を防ぐ
  • おみくじを引いている最中だけメッセージを変える
  • おみくじを引く処理完了後に別の画面に遷移する

といったように、「操作」の状態に応じた処理を容易に実装することができるようになります。

本記事が少しでも参考になれば幸いです!

さいごに

Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!

https://flutteruniv.com?invite_id=9hsdZHg0qtaMIr6RPRulAaRJfA83

Flutter大学

Discussion