🧱

【Flutter】Riverpodが扱えるミニマムなBLoCパターンの構築

2024/07/15に公開

弊社ではMVP (Minimum Viable Product) によるFlutterアプリの開発をしております。
MVPは極力リリースまでの期間を短くし、その後も頻繁に仕様変更をする必要があります。
しかし、一般的な開発手法であるRiverpodでのMVVMだと「ModelとViewの混在化」が起こりがちで、リファクタリングに苦労することが良くありました。

そこで、Viewの複雑化を防ぐためのデザインパターンであるBLoCを用いつつ、Riverpodの利便性をなるべく損なわない手法を考案し、少しずつ改良を重ねてまいりました。

BLoCパターンとは

MVCやMVVMなどの設計手法は「ViewとModelを分離する」ことを主目的としています。
ViewとModelで責務が分かれている図
これは、通常であれば問題ありませんが、Flutterはマルチプラットフォーム開発の出来るフレームワークということもあり、Viewが複雑化しやすい傾向にあります。
そのため段々とView側に「UIに関するビジネスロジック」が混じってしまい…。
Viewに Business Logic が混ざっている図
ViewとModelの境界が曖昧になり、「ViewのModel化」が起こってしまいます。
ViewとModelの Business Logic の違いが曖昧になっている図
これではViewにもModelにも書ける処理をどちら側に記述するのかは、個々人の解釈次第になってしまいますね。

そこで、ある程度複雑化したWidgetの集まり(UI Component)と対になるBLoC(Bussiness Logic Component)クラスを作成し、「UIに関するビジネスロジック」を担当させることでViewを極力簡潔にしよう、という設計手法がBLoCパターンです。
View、BLoC、Modelの関係図
これだけではBLoCとViewModelは大差ありませんが、
BLoCパターンには「ViewとBLoC間は値の通知しか行ってはならない」という強い制約があります。
ViewとBLoC間の通知を表した図
これにより、View側が行えることは

  • ボタンが押された等のイベントをBLoCに通知する
  • BLoCから送られてきた値を基にWidgetを構築する、あるいは画面遷移などの特定の動作を行う

に限定されるため、極力ロジックを排したViewになり、「ViewのModel化」を防ぐことが出来ます。

また、送られてきた値を全て処理する必要はありません。
そのWidgetに必要ない値は受け取らなければ良いだけなので、例えばFlutterアプリとFlutterWebで同じBLoCクラスを使用しつつ、View側のDartファイルは別々のものを使用することも出来ます。
これもView側のロジックを排除することの恩恵です。

Riverpod State を隠蔽するBlocState

flutter_riverpodだとWidgetのbuildメソッド内でref.watchを用いるため、BLoCパターンの実現が困難です。
なので、riverpodを用います。

ProviderScopeによって隠蔽されているので見ることは少ないと思いますが、RiverpodのDIコンテナはProviderContainerというクラスです。

riverpodにはProviderScopeが無いので、このProviderContainerを直接扱うことになります。

flutter_riverpodではref(WidgetRef)のwatchlistenで変更を受け取ったり、read(~~~.notifier).stateで値を更新したりしますが、ProviderContainerにもlistenreadがあるので同じことが出来ます。
watchは無いので別途作成する必要があります)

なので、BLoCクラスの作成時にProviderContainerをコンストラクターの引数として渡し、Riverpodへの操作は全てBLoC側が行うことで、Riverpodを使用しつつBLoCパターンが実現可能です。

BLoC側でのRiverpodの操作を担当するのがBlocStateで、RiverpodのProviderと対になるようにインスタンスを生成します。
例えば、入力された文章を保持するinputTextProviderというStateProviderがあった場合、inputTextというBlocStateを作成し、両者を繋ぎます。

RiverpodをBlocStateによって隠蔽している図

ここで重要なのは、View側ではriverpod.dartをインポートしないことです。
ViewはBLoCとのやり取りに集中させるべきであり、Riverpodの存在を知る必要はありません。
(本記事の実装例ではProviderContainerのみインポートしていますが、本来はしなくても良いように実装できます)

UIに関するビジネスロジックを通知するBlocState

BLoCパターンでは、BLoCが「UIに関するビジネスロジック」を担当します。
通常はStatefulWidgetStateの変数(ローカル変数)として持っておき、setStateで更新すると思いますが、先ほどのRiverpodのBlocStateと同じように、通知によってViewとBLoC間でローカル変数が扱えるようにします。

RiverpodのProviderは同じ値が入力された場合に無視する(通知しない)仕様で扱いづらいため、ここはStreamSinkで簡単な通知が行えるクラスを自作します。

ローカルStateとRiverpodのStateをBlocStateによって隠蔽している図

RiverpodのStateもローカルのStateも同じBlocStateであり、Viewからは違いが判りません。

BlocStateの実装

今回、以下の4つのクラスを作成します。

  • BlocState (abstract class)
  • _BlocLocalState (BlocStateを継承)
  • _BlocRiverpodState (BlocStateを継承)
  • BlocStateManager

実装自体はViewとBLoC間の通知のためだけのものなので、クリーンアーキテクチャーなどの他の設計手法と共存できます。

BlocState

import 'package:context_watch/context_watch.dart';
・・・

abstract class BlocState<T> {
  final _controller = StreamController<T>();

  late final Stream<T> _stream = _controller.stream.asBroadcastStream();
  late final Sink<T> _sink = _controller.sink;

  StreamSubscription<T>? _streamSubscription;

  final _listenList = <Future<void> Function(T, bool)>[];
  Future<void> Function(T, bool)? _uniqueListen;

  T get value;

  void _listen(void Function(T value) onData, bool unique, bool distinct, bool immediately);

  void listen(void Function(T value) onData, {bool distinct = true, bool immediately = false}) =>
      _listen(onData, false, distinct, immediately);

  void uniqueListen(void Function(T value) onData, {bool distinct = true, bool immediately = false}) =>
      _listen(onData, true, distinct, immediately);

  void add(T value);

  T watch(BuildContext context) {
    final snapShot = _stream.watch(context);
    return snapShot.hasData ? snapShot.data! : value;
  }

  void _close() {
    _streamSubscription?.cancel();
    _sink.close();
    _controller.close();
  }
}

BlocState_BlocRiverpodState_BlocLocalStateの親クラスであり、以下のメソッドが公開されています。
どれもRiverpodと同等の機能を提供するためのものです。

  • value
    Riverpodのref.readの代わり
    BLoCクラスで呼ぶためのものなので、View側での使用は原則禁止です
  • listen
  • uniqueListen
    Widgetのbuild内で使用するlisten
    listenだと再描画される度に_listenListに追加されてしまうため
  • add
    Riverpodのref.read(~~~.notifier).statesetStateの代わり
    値を更新し、listenに変更を通知します
  • watch
    Riverpodのref.watchの代わり
    context_watchを使用
    コードを短くするためのものなので、StreamBuilderを使うのであれば不要です

_BlocLocalState

class _BlocLocalState<T> extends BlocState<T> {
  T _value;

  
  T get value => _value;

  _BlocLocalState(this._value) : super() {
    _streamSubscription = _stream.listen((value) {
      final isChange = _value != value;
      _value = value;
      if (_uniqueListen != null) _uniqueListen!(value, isChange);
      for (final listen in _listenList) listen(value, isChange);
    });
  }

  
  void _listen(void Function(T value) onData, bool unique, bool distinct, bool immediately) {
    final listen = distinct
        ? (T value, bool isChange) async {
            if (isChange) onData(value);
          }
        : (T value, bool isChange) async => onData(value);

    if (unique) {
      _uniqueListen = listen;
    } else {
      _listenList.add(listen);
    }

    if (immediately) listen(value, true);
  }

  
  void add(T value) {
    if (!_controller.isClosed) _sink.add(value);
  }
}

BlocLocalStateはUIに関する値やイベントを通知するためのクラスです。
出来ることはRiverpodのStateProviderとほとんど同じです。
listendistinctfalseの場合、addに以前と同じ値が入力されてもlistenが呼ばれます。

_BlocRiverpodState

import 'package:riverpod/riverpod.dart';
・・・

class _BlocRiverpodState<T> extends BlocState<T> {
  late final ProviderSubscription _providerSubscription;
  final ProviderContainer _container;
  final ProviderListenable<T> _provider;

  
  T get value => _provider.read(_container);

  _BlocRiverpodState(this._container, this._provider) : super() {
    _providerSubscription = _container.listen<T>(_provider as dynamic, (_, next) async {
      if (!_controller.isClosed) _sink.add(next);
      for (final listen in _listenList) listen(next, true);
      if (_uniqueListen != null) _uniqueListen!(next, true);
    });
  }

  
  void _listen(void Function(T value) onData, bool unique, bool distinct, bool immediately) {
    final listen = distinct
        ? (T value, bool isChange) async {
            if (isChange) onData(value);
          }
        : (T value, bool isChange) async => onData(value);

    if (unique) {
      _uniqueListen = listen;
    } else {
      _listenList.add(listen);
    }

    if (immediately) listen(_container.read(_provider as dynamic), true);
  }

  
  void add(T value) {
    if (!_controller.isClosed) _sink.add(value);
    if (_provider is StateNotifierProvider || _provider is AutoDisposeStateNotifierProvider) {
      _container.read((_provider as dynamic).notifier).setState(value);
    } else if (_provider is StateProvider || _provider is AutoDisposeStateProvider) {
      _container.read((_provider as dynamic).notifier).state = value;
    }
  }

  
  void _close() {
    _providerSubscription.close();
    super._close();
  }
}

BlocRiverpodStateはRiverpodのProviderのラッパークラスです。
Riverpodの仕様上distinctfalseに設定しても値の重複を許容できません。

BlocStateManager

class BlocStateManager {
  final _list = <BlocState>{};

  BlocStateManager();

  BlocState<T> local<T>(T value) {
    final state = _BlocLocalState<T>(value);
    _list.add(state);

    return state;
  }

  BlocState <T> riverpod<T>(r.ProviderContainer container, r.ProviderListenable<T> provider) {
    final state = _BlocRiverpodState<T>(container, provider);
    _list.add(state);

    return state;
  }

  void dispose() {
    for (final state in _list) state._close();
  }
}

BlocStateManagerは、一つのBLoCクラス内で使用するBlocStateの作成と破棄を効率よく行うためのクラスです。
実装は単純で、BlocStateが作成される度に配列に追加していき、disposeが呼ばれるとまとめて破棄する処理を行っているだけです。

実際に使ってみる

今回作成した4つのクラスを使って実際に実装してみます。

まず、RiverpodのStateProviderとしてinputTextProviderを定義します。

final inputTextProvider = StateProvider((_) => "");

次に、BLoCクラスを以下のように書きます。

class ExampleBloc {
  final ProviderContainer _container;
  final _state = BlocStateManager();

  late final inputText = _state.riverpod(_container, inputTextProvider);

  late final buttonTapped = _state.local(false);
  late final gotoNextPage = _state.local(false);

  ExampleBloc(this._container) {
    buttonTapped.listen((_) => gotoNextPage.add(true), distinct: false);
  }

  void dispose() => _state.dispose();
}

late finalで宣言している変数がBlocStateです。

inputTextProviderinputTextと繋ぎ、ローカルのStateとしてbuttonTappedgotoNextPageを定義しています。
bool型にしていますが、実際は使うことが無いので型は何でも良いです(void型にしたかったのですがLinterに怒られました)
コンストラクター内でlistenして、buttonTappedが更新されるとgotoNextPageも更新されるようにしています。

最後にView側の実装ですが、ProviderContainerをBLoCに渡す必要があります。
色々な方法がありますが、今回はproviderを使用します。

void main() {
  runApp(ContextWatch.root(child: const MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) => Provider(
        create: (_) => ProviderContainer(),
        child: const MaterialApp(home: ExamplePage()),
      );
}

class ExamplePage extends StatelessWidget {
  const ExamplePage({super.key});

  
  Widget build(BuildContext context) => Provider(
        create: (_) => ExampleBloc(context.read<ProviderContainer>()),
        child: Scaffold(
          appBar: AppBar(),
          body: Container(
            alignment: Alignment.center,
            margin: const EdgeInsets.all(20),
            child: const _ExampleBodyPage(),
          ),
        ),
      );
}

class _ExampleBodyPage extends StatefulWidget {
  const _ExampleBodyPage();

  
  State<_ExampleBodyPage> createState() => _ExampleBodyPageState();
}

class _ExampleBodyPageState extends State<_ExampleBodyPage> {
  final textEditingController = TextEditingController();

  
  Widget build(BuildContext context) {
    final bloc = context.read<ExampleBloc>();

    bloc.gotoNextPage.uniqueListen((_) {
      Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ExamplePage()));
    }, distinct: false);

    bloc.inputText.uniqueListen((value) {
      if (textEditingController.text != value) textEditingController.text = value;
    }, immediately: true);

    return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
      Text("inputText is \"${bloc.inputText.watch(context)}\"", style: Theme.of(context).textTheme.headlineMedium),
      TextField(controller: textEditingController, onChanged: bloc.inputText.add),
      Container(
        margin: const EdgeInsets.only(top: 20),
        child: ElevatedButton(
          onPressed: () => bloc.buttonTapped.add(true),
          child: const Text("Go to next page"),
        ),
      ),
    ]);
  }
}

ContextWatch.rootcontext_watchを用いるために必要です。
watchを実装しないのであれば不要)

これでTextFieldに入力した文章がinputTextProviderに反映され、次の画面に遷移しても保持されるBLoCパターンが出来ました。

新しいStateを作る

この手法は単純な構造なので、Riverpod以外のStateを追加することも出来ます。

例えば、将来的にはSwiftやKotlinにある値の変化を通知するNativeStateも検討しています。
addメソッドを用いるとMethodChannel経由で値の更新が行え、listenメソッドを用いるとEventChannel経由で値の変化が通知されます。

Riverpod以外の状態管理パッケージを用いたい時も、新しいStateを作れば良いだけです。View側から見るとどれも同じBlocStateでしかありません。

おわりに

この設計手法は必要に駆られて少しずつ改良を重ねていったものなので、まだ足りないものや改善点が多々あります。

とはいえリファクタリングをする時も、View側のロジックを排除し、BLoC内のロジックをModelに移していくだけで綺麗なコードに出来るという点で、判りやすく便利です。

参考文献

備考

本記事のコードは商用・非商用関係なく自由に改変して使用することが出来ますが、オープンソースとして公開するコードに含めないでください。
(将来的に私がpub.devに公開する可能性があるためです)

本記事は以下の環境で検証しました

Flutter 3.22.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 761747bfc5 (5 weeks ago) • 2024-06-05 22:15:13 +0200
Engine • revision edd8546116
Tools • Dart 3.4.3 • DevTools 2.34.3
GitHubで編集を提案
合同会社zoome(ズーム)

Discussion