ここ1年くらいFlutterで開発していて、Model-View-ViewModel(MVVM)を採用したけどViewModelの役割が大きいなど色々課題を感じていて、より良い形はないかなとFlux, Reduxなどのドキュメントを見ている。 とは言ってもFluxやReduxなどドキュメント見る限り結構めんどくさい事が多い印象で実際に開発してると大変そうだな〜と感じている。
なので、Stateの管理をRiverpodに任せてそれを薄くラップするくらいでよりシンプルな形に出来ないか考えている。
前提
- The Elm ArchitectureをTEAと略します。
- Model-View-UpdateをMVUと略します。
- MVUというかThe Elm Architectureはこのあたりがわかりやすいです
- フワッとわかった気になるElm入門 - Qiita (サクッと目を通しておくと理解が早い)
- 図解 The Elm Architecture の流れ - Qiita
- Riverpod自体がStateを更新する前に処理を行ったり、更新したあとに処理を行うことは出来ないので自前で行う必要がある
- Reduxでめんどくさそうだなと思っているのは1つのStoreを使い回すこと・Action objectを作ってdispatchしてStateを更新するあたりです。
- 1つのStoreを使い回さないのはRiverpodを使えば解決できます
自分なりの理解
アプリには以下のようなフェーズがあると思っている。
- 最初に値を取得しStateに反映させる「初期化表示」フェーズ
- ユーザーの入力によって処理を行ってStateを更新しViewに反映させる「更新表示」フェーズ
それをMVUに当てはめて図にするとこんな感じ
Flutter WidgetがView、StateがModel、ActionsがUpdateを行うくんです。
初期化表示のときもUpdateを経由するのでは?と思っていたのだけど、TEAのドキュメントを見る感じそういうわけではなさそうだった。
またTEAにはCmd(Command)という概念があって、そこで副作用のある処理を行ってupdateにstateを更新させる形になっていそうだけど、Actionsという名前を使うことにした。
さっそく実装する
カウンターアプリを作るのだが、ただlocal stateの数字を増やすだけのアプリだと簡単すぎるので、以下の仕様があるアプリをつくる。
- カウント数はSharedPreferenceに保存される
- アプリを立ち上げた時は、保存されたカウント数を非同期で取得し表示する
- カウント数を取得している間はLoadingを表示する
- カウントはユーザーの入力により増加し、同時にSharedPreferenceに保存される
図にするとこんな感じ
図だとわかりにくい気がしたのでコードにコメントしていく
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; final counterNotifierProvider = StateNotifierProvider<CounterNotifier, AsyncValue<int>>( (ref) => CounterNotifier(ref.read)..initialize(), ); final sharedPreferenceProvider = FutureProvider( (ref) async => SharedPreferences.getInstance(), ); final actionsProvider = Provider((ref) => CounterActions(ref.read)); /// CounterのStateをクラスで定義している。 /// 読み書きを行うStateで初期値をasync/awaitで読み込む時はStateNotifierを使う。 class CounterNotifier extends StateNotifier<AsyncValue<int>> { CounterNotifier(this.read) : super(const AsyncValue<int>.loading()); final Reader read; /// ここで初期値を取得している /// AsyncValueを利用することで、1つのStateで読み込み中, エラー, データを表現できる Future<void> initialize() async { await Future<void>.delayed(const Duration(seconds: 1)); final sharedPreference = await read(sharedPreferenceProvider.future); state = AsyncValue.data(sharedPreference.getInt('counter') ?? 0); } /// incrementするメソッド /// このクラスはStateなので自分のStateしか更新しない /// 本来ここは副作用がない(入力が同じなら何度実行してもStateが同じ結果になる)のがテストをしやすいと思うのだが、メソッドである以上そんな制約持たせるの無理じゃない?と思うので気にしないことにした。 /// Elmは言語仕様でそういう制約があるみたい。 Future<void> increment() async { final data = state.data; if (data == null) { // loading or error return; } final newCount = data.value + 1; state = AsyncValue.data(newCount); } } /// ユーザーの入力を受け取り、Stateの更新やBackend APIとやり取りを行うクラス class CounterActions { CounterActions(this.read); final Reader read; Future<void> increment() async { /// CounterNotifierのincrementを呼び出してStateを更新 await read(counterNotifierProvider.notifier).increment(); /// LocalStorageのデータも新しいデータで更新 final newCount = read(counterNotifierProvider).data?.value ?? 0; final sharedPreference = await read(sharedPreferenceProvider.future); await sharedPreference.setInt('counter', newCount); } } Future<void> main() async { runApp( const ProviderScope(child: MyApp()), ); } class MyApp extends ConsumerWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( home: Home(), ); } } class Home extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar(title: const Text('Counter example')), body: Center( child: Consumer(builder: (context, ref, _) { /// 初期値の読み込みやStateが変更されたらrebuildなどwatchすれば行われる final count = ref.watch(counterNotifierProvider); /// 非同期でカウント数を読み込むので、loading, errorなどをちゃんと表現する return count.map( data: (data) => Text('${data.value}'), loading: (_) => const CircularProgressIndicator(), error: (_) => const Text('On Error'), ); }), ), floatingActionButton: FloatingActionButton( /// ここでStateを更新するのではなくActionsを経由する onPressed: () => ref.read(actionsProvider).increment(), child: const Icon(Icons.add), ), ); } }
まとめ
このようにStateの初期化の処理を行い表示するフェーズ・ユーザーの入力に起因して処理を行いStateを更新して表示するフェーズがある。
ViewModelのようにすべてのことをViewModelが行うのではなくStateに初期化やActionsに更新の役割を与えることで責務の分離がより出来るのではないかと思っている。