Flutterでアプリ開発を深く進めていたら、Widget間のデータのやり取りや状態(State)の管理、UIの制御が複雑になり、シンプルなアプリの域を超えてしまうとデータ管理が難しいなと思い、「状態管理」をするためにFlutterにソフトウェアアーキテクチャを導入することにしました。
今回は、Web開発で以前に利用した事があり、Flutterでも利用可能な「Redux」アーキテクチャパターンを採用してみることにしました。
この記事では、FlutterでどのようにReduxアーキテクチャパターンを導入・利用していくのかについて紹介していきます。
目次
Reduxとは?
Reduxアーキテクチャは、元々あるFluxというアーキテクチャパターンを拡張(発展)して作られたものです。
Reduxについては、Flutter内にどのように実装するかに焦点を当てて紹介しますので、
ここではざっくりしか説明しません。
Reduxの特徴は、「Store」と呼ばれるオブジェクトの中で、アプリケーション全ての状態を保管して、
呼び出し、更新の全てをStoreを通して行います。
商品在庫を保管、加工できる倉庫のようですね。
そして、この「Store」はアプリケーションに1つ限り、唯一の存在でなければいけないという規約があります。
1つの場所で一元管理する事で、データ管理の複雑化を防ぎます。
状態変化の流れは以下の通りです。
■画面から値の更新
「View」→「Action」①→「Reducer」②→「Store」
■更新を画面に伝える
「View」←③「Store」
①「View(画面)」から何らかの「Action」を「Reducer」へ渡す。
②「Reducer」で「View」から渡ってきた「Action」を解析して、「Store」内で管理している状態データを更新する。
③「Store」から、更新された状態データを「View」へ受け渡します。
↓Reduxについてはこちらの記事が参考になったので、一度ご覧になってみてください。
FlutterプロジェクトにReduxを実装する
ここから先はこちらの記事を参考にさせていただきました。
①Reduxライブラリを導入する
pubspec.yamlにflutter_reduxを導入します。
1 2 3 4 |
dependencies: flutter: sdk: flutter flutter_redux: ^0.6.0 # ←追加 |
②Storeに取り入れるState(状態管理データ)クラスを作成する
Storeに管理させるデータを「State」クラスを作成して管理します。
例えば、選択された値、ON/OFF、カウンターなどをStateクラスの中にメンバ変数として宣言します。
例として、Navigationの選択されたindex値を保持したりするNavigationに関するStateを管理するNavigationStateクラスを作成しました。
1 2 3 4 5 6 |
/// Navigationステート @immutable class NavigationState { final int index; NavigationState({this.index = 0}); } |
classの上についているアノテーション「@immutable」は「不変」を意味し、クラス内のメンバ変数が変更不可であることを示す事ができます。
これはRedux3原則の一つの「stateは読み取り専用」を実現するためです。
メンバ変数は全てfinalで宣言し、「Reducer」で更新させる為にコンストラクタでメンバ変数を初期化しています。
しかし、このようなStateクラスをたくさん用意した場合に、後述するStoreの初期化時に複数のStateクラスを一気に初期化して保持できるようひと工夫する必要があります。
RootStateというクラスを作成し、全てのStateクラスをメンバ変数として保持します。
そして、コンストラクタで全てのStateクラスを初期化させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/// ↓追加:根本ステート @immutable class RootState { // 全てのStateクラスをメンバ変数として保持し、コンストラクタで // 初期化します final NavigationState navigation; RootState({ this.navigation }); } /// Navigationステート @immutable class NavigationState { final int index; NavigationState({this.index = 0}); } |
③状態をどのように変更するかの指標となるActionクラスを作る
次に、「Action」クラスを作ります。
Stateクラスの更新方法は、「Reducer」が画面から渡ってきた「Action」情報を元に判断します。
その為、単に「class ChangeNavigationIndexAction{}」のような空っぽのクラスでOKです。
ただし、「画面から指定した値にデータを変更したい」などの場合はパラメータを受け取る必要があるので、
以下のように宣言します。
1 2 3 4 5 6 |
/// 画面から渡ってきたIndex値に変更させるActionクラス /// 画面からパラメータを受け取るにはメンバ変数を宣言してコンストラクタで初期化させればOK class ChangeNaviIndexAction { final int index; ChangeNaviIndexAction({this.index}); } |
Actionクラスも必要な更新方法の数だけ複数作ります。
④Actionを受け取ってStateクラスの情報を更新するReducerを作る
上でも言った通り、Reducerで受け取ったActionを判別して適切にStateクラスを更新します。
1 2 3 4 5 6 7 |
NavigationState navigationReducer(NavigationState state, action) { if (action is ChangeNaviIndexAction) { return NavigationState(index: action.index); } // 上記意外は元のStateを返す return state; } |
Stateクラスごとに Reducerを作って管理します。
Stateクラス型のReducerは関数で、引数に現在のState、Action情報を渡します。
IF文を利用して、渡ってきたActionを判定します。
引数で渡ってきたactionはActionクラスにパラメータを設定していた場合、引き出す事ができます。
最後にStateクラスを新しい値で作り直します。
こちらもRootReducerを作って、Stateクラスの初期化を管理します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/// ↓追加 RootState rootReducer(RootState state, action) { // RootStateのパラメータを利用して全てのStateクラスを初期化する return RootState( navigation: navigationReducer(state.navigation, action), ); } NavigationState navigationReducer(NavigationState state, action) { if (action is ChangeNaviIndexAction) { return NavigationState(index: action.index); } // 上記意外(例外の場合) return state; } |
⑤はじめに呼ばれるStatelessWidget内でStoreをインスタンス化
下準備が整ったので、Storeを構築します。
StatelessWidget内で、Storeをインスタンス化してメンバ変数として保持します。
1 2 3 4 5 6 7 8 9 10 11 12 |
class MyApp extends StatelessWidget { // ストア(ストアの管理はこれ一つだけにする) final store = Store<RootState>( rootReducer, initialState: RootState( // 初期化 navigation: NavigationState(), ), ); ////// 以下省略 ///////// } |
⑥StoreConnectorを使ってWidgetの状態を更新する
以下はNavigationの切り替えによって画面の表示内容が切り替わるWidgetの例です。
本来このような動的な動きをさせるにはStatefulWidgetを使用してStateを更新させる必要がありますが、
Reduxで状態管理しているので、StatelessWidgetでも動的更新が可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
// NavigationWidget構成 return StoreProvider( store: store, child: Scaffold( body: StoreConnector<RootState, int>( distinct: true, converter: (store) => store.state.navigation?.index, builder: (context, index) { // 表示させる各ページのウィジェット return _createPages(index); } ), bottomNavigationBar: StoreConnector<RootState, int>( distinct: true, converter: (store) => store.state.navigation?.index, builder: (context, index) { return BottomNavigationBar( items: const [ BottomNavigationBarItem( icon: Icon(Icons.home), title: Text( 'ホーム', style: TextStyle( fontSize: 12, ) ), ), BottomNavigationBarItem( icon: Icon(Icons.trending_up), title: Text( 'おすすめ', style: TextStyle( fontSize: 12, ) ) ), BottomNavigationBarItem( icon: Icon(Icons.done), title: Text( 'ピックアップ', style: TextStyle( fontSize: 12, ) ), ), BottomNavigationBarItem( icon: Icon(Icons.loyalty), title: Text( 'ジャンル', style: TextStyle( fontSize: 12, ) ), ), BottomNavigationBarItem( icon: Icon(Icons.add_shopping_cart), title: Text( '人気', style: TextStyle( fontSize: 12, ) ), ), ], currentIndex: _currentIndex, fixedColor: Colors.blueAccent, onTap: _changeNavigation, type: BottomNavigationBarType.fixed, ); } ) ), ); } /// インデックス変更(ストアのインデックスも変更) void _changeNavigation(int index) { _currentIndex = index; store.dispatch(ChangeNaviIndexAction(index: index)); } /// ページごとのwidget生成 Widget _createPages(int index) { String test; Widget _pages; switch(index) { case 0: // ホーム test = "ホーム"; _pages = Center( child: Text( test, style: TextStyle( fontSize: 20, ) ), ); break; case 1: // おすすめ test = "おすすめ"; _pages = Center( child: Text( test, style: TextStyle( fontSize: 20, ) ), ); break; case 2: // ピックアップ test = "ピックアップ"; _pages = Center( child: Text( test, style: TextStyle( fontSize: 20, ) ), ); break; case 3: // ジャンル test = "ジャンル"; _pages = Center( child: Text( test, style: TextStyle( fontSize: 20, ) ), ); break; case 4: // 人気 test = "人気"; _pages = Center( child: Text( test, style: TextStyle( fontSize: 20, ) ), ); break; } return _pages; } } |
Stateクラスの状態を更新するには「store.dispatch(ChangeNaviIndexAction(index: index));」のようにActionクラスとパラメータ(有れば)を指定することでReducerが検知し、Stateを変更させる事ができる仕組みです。
Widgetの動的に更新をかけたい箇所は「StoreConnector」でラップします。
1 2 3 4 5 6 7 8 9 |
StoreConnector<RootState, int>( distinct: true, // 値に変化があったときだけ更新する converter: (store) => store.state.navigation?.index, // NavigationStateのindexに変化があったとき検出 builder: (context, index) { // 更新させるWidgetを定義 // 引数で最新のindexが取得できる return _createPages(index); } ) |
実際に動かしてみると、StatelessWidgetでも画面が更新されるのが分かります。
まとめ
かなり複雑で、他の文献をあさったりと、理解に苦しみました…。
実際に構築してみると、それぞれのWidget間でデータの受け渡しが煩わしくなくシンプルに済むので、
複雑になりそうなアプリの構築には真価を発揮するのではないかと思います。