Flutterのアプリ設計(Redux)

この記事はFlutter 全部俺 Advent Calendar 22日目の記事です。

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的にFlutterの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はTwitterにお願いします。

Redux

ReduxはもともとReactの状態管理をするためのライブラリで、以下のような3原則があります。

ソースが複数あるデータは、常に不整合の可能性があります。たとえば、タイムラインでは投稿にいいねがついているのに、 詳細画面ではいいねがついていないようなことが起ってしまいます。 これを開発者の注意で解決するのはとても骨の折れる作業です。

Reduxでは、状態管理を巨大なオブジェクト一つに任せることによってデータの不整合を防いでいます。 巨大なJsonにアプリの全ての状態が書いてあって、それを変更していくことで状態を変更するようなイメージです。

状態を直接操作することはできず、Actionオブジェクトを発行することでしかできないように決められています。

これによって、意図しない変更が起こってしまったり、変更の競合が起こってしまったりすることを防ぐことができます。

上でJsonを変更していくと書きましたが、状態はread only(読み取り専用)で変更してはいけないというルールがあります。 ではどうやって状態を変更するのかというと、元の状態から変更したい部分だけ変えた状態を新しく作る関数を実行します。

この関数は(元の状態, 変更したい情報) => 新しい状態という形式の純粋関数で、 状態を受け取って状態を返す以外は何もしてはいけません。

純粋な関数のみで状態変更を実装することで複雑になりやすい状態変化を見通しやすくしたり、テストが容易になったりします。

ここまでが、Reduxの教科書的な説明です。実際にFlutterとReduxを組み合わせて実装してみましょう。

ReduxをFlutterで使う

flutter_reduxというパッケージを使って実装していきます。特に理由がなければよく使われているこのライブラリに 乗っかっておくのが良さそうです。

今回もカウンターアプリを作っていきます。

Stateを作る

今回は状態がcountしかないので、Stateに階層構造を持たせずにそのままint型を入れてももんだありませんが、 実用性に乏しいので、RootStateクラスとCounterStateクラスを作っておきます。Jsonで表すと以下のようなイメージです。

{
  "counter": {
    "count": 0
  }
}

RootStateクラスとCounterStateクラスの定義は以下の通りです。 Stateクラスは全てデフォルト引数を設定しておくようにしてください。 こうしておくことで、あとでRootStateの初期化が簡単になります。

class CounterState {
  final int count;

  CounterState({this.count = 0});
}

class RootState {
  final CounterState counter;

  RootState({this.counter = CounterState()});
}

次にIncrementActionクラスを定義します。いくつ数を増やすかのcount定数だけを持っています。

class IncrementAction {
  final int count;

  IncrementAction(this.count);
}

IncrementActionを処理するcounterReducerを実装します。action引数はdynamic型で受け取っていますが、 if (action is IncrementAction) {...}で型の条件を書くと、カッコ内では自動的にキャストしてくれます。

CounterState counterReducer(CounterState state, action) {
  if (action is IncrementAction) {
    return CounterState(count: state.count + action.count);
  } else {
    return state;
  }
}

最後にRootStateを処理するrootReducerを実装します。この関数はcounterReducerに処理を丸投げしているだけです。

RootState rootReducer(RootState state, action) {
  return RootState(
    counter: counterReducer(state.counter, action)
  );
}

あとは、ここまで作ったRootStaterootReducerを使ってStoreを作るだけです。

final store = Store<RootState>(
  rootReducer, 
  initialState: RootState(), 
);

ここで作ったstoreがアプリ内で唯一状態を持っているインスタンスです。これをWidgetツリー全体から使えるようにするために、 flutter_reduxパッケージでは、StoreProviderが提供されています。

StoreProviderの使い方はProviderパッケージと同じです。 Providerパッケージに関して詳しくは 6日目の記事7日目の記事を読んでください。

アプリ全体からstoreにアクセスしたいので、MaterialAppよりも上にStoreProviderをおきます。

class MyApp extends StatelessWidget {
  final store = Store<RootState>(
    rootReducer,
    initialState: RootState(),
  );

  @override
  Widget build(BuildContext context) {
    return StoreProvider(
      store: store,
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(title: 'Flutter Demo Home Page'),
      ),
    );
  }
}

これで準備は完了です。画面からReduxのstoreにアクセスしてみましょう。

StoreProviderで用意したstoreにアクセスするためには、StoreBuilderStoreConnectorを使います。

まずStoreBuilderから見ていきましょう。このWidgetbuilder関数にstoreからstoreを渡し、RootStateに 変更があるたびにbuilder関数を実行して再描画を行います。

            StoreBuilder<RootState>(
              builder: (context, store) {
                return Text(
                  '${store.state.counter.count}',
                  style: Theme.of(context).textTheme.display1,
                );
              },
            ),

つまり、storeに対するあらゆる状態の変更が、StoreBuilderを使っている全てのWidgetの再描画を引き起こすということです。 これでは、パフォーマンスに対する影響があるのは明らかです。

この問題を解決するためには、StoreConnectorというWidgetを使います。

StoreConnectorconverter関数にstoreを渡して、その返り値をbuilder関数に渡します。

例えば今回の場合は、storeからcounterだけを取り出すconverterを渡しています。 オリジナルのReduxを使ったことがある人はselectorの概念に近いです。

            StoreConnector<RootState, int>(
              converter: (store) => store.state.counter?.count,
              builder: (context, count) {
                return Text(
                  '$count',
                  style: Theme.of(context).textTheme.display1,
                );
              },
            ),

さらにdistinct: trueとすることで、converterの返り値(今回の場合はcount)に変更があったときにだけ Widgetの再描画を行うようになります。これでさらに無駄なWidgetの描画を減らすことができます。

            StoreConnector<RootState, int>(
              distinct: true,
              converter: (store) => store.state.counter?.count,
              builder: ...
            ),

基本的にStoreBuilderよりもこちらを使うようにしましょう。

storeの状態を変更したい場合は、store.dispatch関数を使いましょう。StoreConnectorには、 storeを受け取って関数を返す関数を渡します。

      floatingActionButton: StoreConnector<RootState, VoidCallback>(
        converter: (store) => () => store.dispatch(IncrementAction(1)),
        builder: (context, dispatchIncrement) {
          return FloatingActionButton(
            onPressed: dispatchIncrement,
            tooltip: 'Increment',
            child: Icon(Icons.add),
          );
        },
      ),

これで、ボタンを押すとカウントの表示が変更されるようになりました。

テストを書く

Reduxの特徴の一つにテストが簡単にかけることが挙げられます。今回は状態が少ないのでテストが必要な部分が少ないですが、 counterReducerのテストを書いてみましょう。

void main() {
  test('Counter value should be incremented', () {
    final currentState = CounterState();
    expect(nextState.count, 0);
    
    final action = IncrementAction(1);
    final nextState = counterReducer(currentState, action);

    expect(nextState.count, 1);
  });
}

現在の状態とIncrementAction(1)counterReducerに渡すと、返り値の新しい状態でcountが増加しているかを テストするコードです。 counterReducerは純粋関数なので、このテストコードだけで動作を保証することができます。

21日目: Flutterのアプリ設計(Bloc) :

https://itome.team/blog/2019/12/flutter-advent-calendar-day21

23日目: Flutterのアプリ設計(MobX) :

https://itome.team/blog/2019/12/flutter-advent-calendar-day23