タオルケット体操

サツバツいんたーねっとでゲームとかガジェットのレビューとかをします

BLoCにおけるリモートデータの状態遷移のパターンをくくりだす方法

Sponsored link

Dart否定して終わりだとあもりにあわれ。
おれにこれしかなんだ! だから、これがいちばんいいんだ!というわけで現実と戦う方法を模索。

元ネタ: https://elmprogramming.com/remote-data.html

ちなみにBLoCはすでにオワコン化しつつあるようなので新規でコード書く場合はよっぽどのモチベーションがない限りは避けるべきでしょう。ちょろっとコンセプト読んだだけで微妙なのわかってしかるべきなのに今さら「やっぱダメで〜すww」とかほんともうね、そういうのはフロントエンドだけにしておいてくれ

モチベーション

ざっくり99%くらいのFlutterアプリはなんらかのリモートデータを取得して画面に表示する処理を持っているはずです。

その場合、データには

  1. 初期状態

  2. 問い合わせ中

    1. 成功
    2. 失敗

というフローが存在しており、Viewはそれぞれに応じてクルクルさせたりエラーを出したり握り潰したりしているはずです。

これに対処する方法は色々なアプローチがあるかとおもいますが、一番メジャーなBLoC実装であろう felangel/bloc *1 から一例をあげますと https://bloclibrary.dev/#/fluttertodostutorial?id=states のように、abstractなStateというクラスを継承したLoadingStateを作る……というドメイン貧血なStateパターン(このStateはデザパタのState)があります。

が、実際に使ってみるとこのパターンは生み出す負が多いことに気がつくはずです。
例えばStateが他の種類の状態遷移を持っている場合。この場合は組み合わせの数や、リモートステートの変化越しへのデータ引き継ぎの難しさやそれに伴う不要なfieldの増加……
例のBLoC実装の例を覗いても、バリデーションを行っているフォームではロード中、成功、失敗が派生クラスではなく、 isSubmitting などのbooleanフィールドとして実装されています。これだと実装の一貫性を担保しにくいですよね。

実際、列挙型がデータを持てず、パターンマッチなどがないDartの言語仕様やBLoCのコンセプト的に考えても継承を濫用する前者のやり方よりもシンプルな後者のやり方がベターでしょう。
普通にStreamを使ってBLoCを実装しているプロジェクトは後者で実装しているんじゃないかと想像しています。

パターンは抽象化したい

とはいえ頻出するパターンは抽象化したいよね。じゃあ抽象化しましょうね。
出来上がったものがこちらです。

テストやコメントから察する感じだとおもいますが

class SuperBeautifulBloc with RemoteStateHandlable {
  @override
  final RemoteStateController remoteStateController;

  final hotpepperController = // ...

  RemoteStateHandler() : remoteStateController = RemoteStateController();
}

という感じで既存のBLoC実装にmix-inすることで、このブロックが非同期アクセスするときの状態遷移のための便利メソッドを使えるようになります。
たぶんこんなかんじ:

Future<State> loadData() async {
  loading();
  final response = await someRepository.fetch();
  if (response.ok) {
    succeed();
    return State(response.data);
  } else {
    failed(response.error);
    return Future.error(response.error);
  }
}

あとはWidget側が結果をStreamBuilderで監視します。

Widget build(BuildContext context) {
  final theBloc = BlocProvider.of<TheBloc>(context);
  return StreamBuilder<RemoteStatus>(
    stream: theBloc.remoteStateController,
    builder: (context, snapshot) {
      final data = snapshot?.data.type;
      if (data == null) return LoadingWidget();
      switch (data) {
        case RemoteStatusType.NotAsked:
        case RemoteStatusType.Loading:
          return LoadingWidget();
        case RemoteStatusType.Failed:
          return ErrorIndicate(data.reason);
        case RemoteStatusType.Succeed:
          return Screen(...);
      }
    }
  );
}

これでリモートアクセスと、それに伴う状態遷移、そしてViewへの適用を一つのパターンとしてくくりだすことができました。
ちなみにDartはenumの網羅性チェックはしてくれるようです。なのでenumと組み合わせるときは極力default節は書かないようにしましょう。

あとがき

RemoteStateが RemoteState<Success, Failed> ではなく、エラーメッセージしか持たないのは汎用性のためです。
まずBLoCは状態管理の中でもViewModelのパターンなので、複数のリモートデータを扱う可能性が高いです。なので状態を RemoteState<T で持ち運ぶと汎用性が落ちてしまうのと、ViewModelとして振る舞いを持たせるのが複雑になります。複数のStreamを統合してよしなに扱いたくなったらRxDartのほうがいいかな、僕はRx系列ライブラリ懐疑論者ですが。
リモートデータの中身の初期化の仕方も、RESTのリクエストを複数で送ったりGraphQLで一括のリソースで初期化したりと、様々なユースケースが考えられますよね。なのでこのRemoteState実装では、あくまでViewがリモートアクセスに伴うローディングやエラーのポップアップを表示するために必要なデータのみを提供することに集中しています。

元ネタがElmで、個人的に同じようなものをTypeScriptで作って運用しているのでそっちに寄せたくなって試行錯誤したのですが、代数的データ型のような持ち方をDartで継承を使って実現して得られたものが満足感くらいだったので今回のやり方に落ち着きました。パターンマッチもないし。enumフィールド + 運用でカバーが言語の特性にマッチしたやりかただとおもいます。

以上。

*1:ちなみに僕はこのライブラリは基本的には使うべきではないとおもっています