【Flutter】Riverpodが扱えるミニマムなBLoCパターンの構築
弊社ではMVP (Minimum Viable Product) によるFlutterアプリの開発をしております。
MVPは極力リリースまでの期間を短くし、その後も頻繁に仕様変更をする必要があります。
しかし、一般的な開発手法であるRiverpodでのMVVMだと「ModelとViewの混在化」が起こりがちで、リファクタリングに苦労することが良くありました。
そこで、Viewの複雑化を防ぐためのデザインパターンであるBLoCを用いつつ、Riverpodの利便性をなるべく損なわない手法を考案し、少しずつ改良を重ねてまいりました。
BLoCパターンとは
MVCやMVVMなどの設計手法は「ViewとModelを分離する」ことを主目的としています。
これは、通常であれば問題ありませんが、Flutterはマルチプラットフォーム開発の出来るフレームワークということもあり、Viewが複雑化しやすい傾向にあります。
そのため段々とView側に「UIに関するビジネスロジック」が混じってしまい…。
ViewとModelの境界が曖昧になり、「ViewのModel化」が起こってしまいます。
これではViewにもModelにも書ける処理をどちら側に記述するのかは、個々人の解釈次第になってしまいますね。
そこで、ある程度複雑化したWidgetの集まり(UI Component)と対になるBLoC(Bussiness Logic Component)クラスを作成し、「UIに関するビジネスロジック」を担当させることでViewを極力簡潔にしよう、という設計手法がBLoCパターンです。
これだけではBLoCとViewModelは大差ありませんが、
BLoCパターンには「ViewとBLoC間は値の通知しか行ってはならない」という強い制約があります。
これにより、View側が行えることは
- ボタンが押された等のイベントをBLoCに通知する
- BLoCから送られてきた値を基にWidgetを構築する、あるいは画面遷移などの特定の動作を行う
に限定されるため、極力ロジックを排したViewになり、「ViewのModel化」を防ぐことが出来ます。
また、送られてきた値を全て処理する必要はありません。
そのWidgetに必要ない値は受け取らなければ良いだけなので、例えばFlutterアプリとFlutterWebで同じBLoCクラスを使用しつつ、View側のDartファイルは別々のものを使用することも出来ます。
これもView側のロジックを排除することの恩恵です。
BlocState
Riverpod State を隠蔽するflutter_riverpodだとWidgetのbuild
メソッド内でref.watch
を用いるため、BLoCパターンの実現が困難です。
なので、riverpodを用います。
ProviderScope
によって隠蔽されているので見ることは少ないと思いますが、RiverpodのDIコンテナはProviderContainer
というクラスです。
riverpodにはProviderScope
が無いので、このProviderContainer
を直接扱うことになります。
flutter_riverpodではref
(WidgetRef
)のwatch
やlisten
で変更を受け取ったり、read(~~~.notifier).state
で値を更新したりしますが、ProviderContainer
にもlisten
とread
があるので同じことが出来ます。
(watch
は無いので別途作成する必要があります)
なので、BLoCクラスの作成時にProviderContainer
をコンストラクターの引数として渡し、Riverpodへの操作は全てBLoC側が行うことで、Riverpodを使用しつつBLoCパターンが実現可能です。
BLoC側でのRiverpodの操作を担当するのがBlocState
で、RiverpodのProviderと対になるようにインスタンスを生成します。
例えば、入力された文章を保持するinputTextProvider
というStateProvider
があった場合、inputText
というBlocState
を作成し、両者を繋ぎます。
ここで重要なのは、View側ではriverpod.dart
をインポートしないことです。
ViewはBLoCとのやり取りに集中させるべきであり、Riverpodの存在を知る必要はありません。
(本記事の実装例ではProviderContainer
のみインポートしていますが、本来はしなくても良いように実装できます)
BlocState
UIに関するビジネスロジックを通知するBLoCパターンでは、BLoCが「UIに関するビジネスロジック」を担当します。
通常はStatefulWidget
のState
の変数(ローカル変数)として持っておき、setState
で更新すると思いますが、先ほどのRiverpodのBlocState
と同じように、通知によってViewとBLoC間でローカル変数が扱えるようにします。
RiverpodのProviderは同じ値が入力された場合に無視する(通知しない)仕様で扱いづらいため、ここはStream
とSink
で簡単な通知が行えるクラスを自作します。
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).state
やsetState
の代わり
値を更新し、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
とほとんど同じです。
listen
のdistinct
がfalse
の場合、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の仕様上distinct
をfalse
に設定しても値の重複を許容できません。
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
です。
inputTextProvider
をinputText
と繋ぎ、ローカルのStateとしてbuttonTapped
とgotoNextPage
を定義しています。
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.root
はcontext_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
Discussion