mthr blog

あれやこれを書いたり

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

単純なカウンター増減を考えるため、カウンターを記録する変数(定数)を持つクラスを作成します。このクラスを 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 で制御したい WidgetStoreConnector で囲みます。他の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 以外にも使用が多い方法であるので、まず使って問題はないと思っています。良い状態管理で、良い開発を。