Flutter with Redux Saga
本稿は Flutter アプリに Redux Saga を適用する方法を紹介します。導入方法を説明するブログはいくつか見かけるのですが、ここはあのブログ、あれは別のブログと、あちこちと参考先が多かったので、まとめました。なお、Redux Saga の自体説明は省きます。詳しくは、こちら (redux-saga/README_ja.md) などをどうぞ。
サンプルアプリ
単純なカウンターの増減を Redux Saga で制御します。コードは GitHub で公開しています。
パッケージのインストール
Redux Saga を実現するためのパッケージはすでにあるので、それらを利用します。
% flutter pub add redux_saga % flutter pub add redux_persist_flutter % flutter pub add flutter_redux % flutter pub add redux_logging
- redux_saga
- 要となるパッケージ
- flutter_redux
- State による Widgets のレンダリング制御を行う
- redux_persist_flutter
- State を永続化する
- redux_logging
- ログを適宜表示する(任意)
Redux
単純なカウンター増減を考えるため、カウンターを記録する変数(定数)を持つクラスを作成します。このクラスを state として、扱っていきます。
@immutable class CounterState{ final int count; CounterState({required this.count}); static initialState(){ return CounterState(count: 0); } }
次は action です。ここは enum
で定義しても良いですが、引数を伴う場合も考えられるので、クラスで定義しています。
class IncreaseCount{ } class AssignCount{ final count; AssignCount({this.count}); }
actions を受けて state を書き換える reducer です。is
でクラス型判定を行い、それぞれに応じて変数を書き換えていきます。引数付きの actions は型判定するので、そのまま利用することができます。
CounterState counterReducer(CounterState state, dynamic action) { if (action is IncreaseCount) { return CounterState(count: state.count + 1); } if (action is AssignCount){ return CounterState(count: action.count); } return state; }
テストも簡易ですが、紹介します。
void main() { test("counter_reducer action IncreaseCount", (){ final state = CounterState(count: 0); expect(state.count, 0); final nextState = counterReducer(state, IncreaseCount()); expect(nextState.count, 1); }); test("counter_reducer action AssignCount", (){ final state = CounterState(count: 10); expect(state.count, 10); final nextState = counterReducer(state, AssignCount(count: 99)); expect(nextState.count, 99); }); }
実際は、1つのモジュールだけでなく、複数のモジュールが運用されます。そのために root も用意します。
@immutable class RootState{ final CounterState counter; RootState({required this.counter}); static initialState(){ return RootState(counter: CounterState.initialState()); } }
RootState rootReducer(RootState state, dynamic action){ return state.copyWith( counter: counterReducer(state.counter, action)); }
Saga
次は、Saga です。例として action が発行されたら、toast を表示します。また、その時に state からカウンター数を取得してみました。
counterSaga() sync* { yield TakeEvery(_increaseCountSaga, pattern: IncreaseCount); } int _selectCount(RootState state) { return state.counter.count; } _increaseCountSaga({dynamic action}) sync* { var result = Result<int>(); yield Select(selector: _selectCount, result:result); yield Call((){ Fluttertoast.showToast(msg: "increase count to ${result.value}"); }); }
前項と同様に、root 用の saga を用意しておきます。
rootSaga() sync* { yield Fork(counterSaga); }
Middleware
それでは、今まで作成してきたものを設定していきます。
Future<Store<RootState>> initializeRedux() async { // create Persistor final persistor = Persistor<RootState>( storage: FlutterStorage(), serializer: JsonSerializer<RootState>(RootState.fromJson), ); // load initial state final initialState = await _loadState(persistor); // create the saga middleware final sagaMiddleware = createSagaMiddleware(); // create store and apply middleware final store = Store<RootState>( rootReducer, initialState: initialState, middleware: [ applyMiddleware(sagaMiddleware), persistor.createMiddleware(), new LoggingMiddleware.printer(), ]); // connect to store sagaMiddleware.setStore(store); // then run the saga sagaMiddleware.run(rootSaga); return store; } Future<RootState> _loadState(Persistor<RootState> persistor) async { try { final initialState = await persistor.load(); return initialState ?? RootState.initialState(); } on Exception { return RootState.initialState(); } }
永続化するための、state にシリアライズを実装します。今回は手動でやってますが、json_serializable などで自動生成しても問題ありません。
class CounterState{ static CounterState fromJson(dynamic json) { try{ return CounterState(count: json["count"] as int); } on Exception { return CounterState.initialState(); } } dynamic toJson() { return {"count": count}; } }
Widgets
準備が整いましたので、Widgetと連携させましょう。main 関数が非同期なのが気持ち悪いところですが、state の保存データを非同期で読み込んでいるので問題ありません。なお persist を利用しているので、WidgetsFlutterBinding.ensureInitialized();
は runApp()
の前に呼んでおく必要があります。
/// main.dart Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final store = await initializeRedux(); runApp(new StoreProvider( store: store, child: MyApp(), )); }
次に、Redux Saga で制御したい Widget を StoreConnector
で囲みます。他のWidget が意味なく再レンダリングされるのを防ぐためです。builder
の第二引数が converter
の戻り値に対応しており、converter
で、state や actions の発行を設定して、それぞれのWidget で表示やタップイベントを行ます。
class CounterPage extends StatelessWidget{ CounterPage(); Widget counterWidget(){ return StoreConnector<RootState, int>( distinct: true, converter: (store) => store.state.counter.count, builder: (context, count){ return Center(child: Text("count:" + count.toString())); }, ); } Widget increaseButtonWidget(){ return StoreConnector<RootState, VoidCallback>( distinct: true, converter: (store) => () => store.dispatch(IncreaseCount()), builder: (context, onPressed){ return TextButton( onPressed: onPressed, child: Text("increase"), ); }, ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("counter"), ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ counterWidget(), increaseButtonWidget(), ],), ); } }
まとめ
長くなりましたが、これで Flutter アプリに Redux Saga を導入できました。Redux Saga は状態管理がシンプルになり、ロジックとUIを切り離すことができるので、非常に便利です。Flutterの状態管理はいくつかありますが、Redux は Flutter 以外にも使用が多い方法であるので、まず使って問題はないと思っています。良い状態管理で、良い開発を。