はじめに
この記事はFlutter Meetup Tokyo #6で話した内容の記事版です。
「Reduxをある程度知っているFlutterエンジニア」を読者として想定しています。
目的は、私が今携わっているサービスであるREQUではどのような設計でアプリを構築しているかの共有です。
Reduxとは
「はじめに」にも書いた通りReduxをある程度知っていることを想定しているので、Reduxについては最低限のポイントだけ説明します。
ReduxはReactJSのフレームワークです。
が、最近ではiOSやAndroidのアプリ開発においてもアーキテクチャとしてよく採用されるようになってきているというのが私の印象です。
Reduxは以下のの2点がポイントだと思っています。
- Unidirectional data flow(単一方向のデータフロー)
- Three principles(Reduxの三原則)
Unidirectional data flow
Reduxのモデルの概要を図にすると、以下のようになっています。
ViewがActionをdispatchすると、ActionはMiddlewareを通りStoreに伝えられます。
Storeは今のStateとdispatchされてきたActionをReducerに渡して新しいStateを作り、その変更をViewがsubscribeします。
英語が多すぎて自分で書いててびっくりしました。笑
要は、データフローが単一方向だとアプリの状態の管理がしやすいというのがポイントです。
状態(State)を変えられる(正確に言うと作り変える)のはReducerだけという制約があるため、開発者はReducer以外から状態を変えられる心配をする必要がなくなり、状態の管理がしやすくなります。
ちなみにFluxもUnidirectional data flowなアーキテクチャとして昨今のアプリ開発ではちょくちょく聞きます。
Three Principles
ReduxにはThree Principles(三原則)があります。
- Single source of truth
- State is read-only
- Changes are made with pure functions
個人的な解釈にして言い直すと、
- アプリケーション全体の状態を1つのStoreで表現
- 状態を変えたかったら新しいState作ってくれ
- 状態変更できるのはReducerだけ
ということだと思っています。
この三原則が大事で、これを守ることでReduxの恩恵を受けることができます。
FlutterでRedux
Dart Packagesにreduxという名前がそのままのライブラリがすでにあります。
これを使うことでFlutterのプロジェクトにReduxを導入することができます。
REQUの場合
REQUの場合、Redux関連のライブラリは以下の4つを使っています。
reduxは上に書いた通り、DartプロジェクトにReduxを導入するためのライブラリです。
flutter_reduxはreduxとWidgetツリーの仲介役的な役割を担ってくれるライブラリです。StoreをWidgetツリー内から使用できるようにするStoreProviderと、StoreとWidgetを繋いでsubscribeの機能を提供するStoreConnectorが主なライブラリの内容です。
redux_loggingはログ出力のために使用しています。
redux_thunkはThunkMiddlewareを提供してくれます。
Thunkとは
Thunkを知らない方向けに簡単に説明します。
Thunkは、こちらも元々redux-thunkというJSのRedux向けのライブラリで、簡単に言うと、非同期処理をActionとして定義できるようにするためのMiddlewareを提供します。
すごいことをやっているように聞こえますが、実装は至って簡単で、Dartだと以下のような実装になっています。
void thunkMiddleware<State>(
Store<State> store,
dynamic action,
NextDispatcher next,
) {
if (action is ThunkAction<State>) {
action(store);
} else {
next(action);
}
}
ThunkActionと言うStoreを引数に取る関数を定義していて、ThunkMiddlewareを通るActionがThunkActionの場合、Storeを渡して実行するだけです。
これでどうして非同期処理が可能になるのかは、次のセクションで説明します。
Action
REQUではActionをclassとして定義しています。
class UpdateSomething {
UpdateSomething(this.id, this.name);
final int id;
final String name;
}
dispatchするときは以下のようになります。
store.dispatch(UpdateSomething(id, name));
APIコールなど、非同期処理が必要な場合は上で説明したThunkActionを使います。
ThunkAction<AppState> fetchSomething() {
return (store) {
// 非同期処理
.then((response) => store.dispatch(UpdateSomething(response)))
.catchError((error) => store.dispatch(UpdateError(error)));
};
}
ThunkActionは関数でありかつstoreを使えるので、上記のように非同期処理を行ってそのレスポンスが返ってきたときにActionをdispatchすることが可能になります。
エラーハンドリングも同様です。
dispatchは普通のActionと同じ要領で行うので、View目線で考えると、ActionかThunkActionかを意識せずに行いたいActionをdispatchすればいいということになります。
store.dispatch(fetchSomething());
View(Widget)
Viewでは上に書いたStoreConnectorを使ってStateの監視とViewの更新を行っています。
class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, _HomePageViewModel>(
distinct: true,
converter: (store) {
return _HomePageViewModel(
name: store.state.homeState.name,
onTap: () => store.dispatch(UpdateName('Next name')),
);
},
builder: (context, viewModel) {
return Scaffold(
body: Center(
child: FlatButton(
onPressed: viewModel.onTap,
child: Text(viewModel.name),
),
),
);
},
);
}
}
ここで実はViewModelが登場しています。
REQUではViewModelを使用していて、State->ViewModel->Viewというアーキテクチャにしています。
こうすることで、tap等のユーザーアクションのロジックをViewModelに任せて、ViewはWidgetの構築だけに集中することができます。
また、distinct: true
にすることで、Stateの変更によってViewModelが変更されたかどうかをみて、変わっていなかったら再描画(setState)を走らせないということが可能になり、冗長な更新が走ることを防ぐことができます。
State
StateはReduxの三原則にあるように、読み取り専用かつ変更不可(状態変えたかったら新しいState作れ)なので、@immutable
アノテーションをつけて定義しています。
@immutable
class HomeState {
HomeState({
@required this.name,
@required this.length,
});
final String name;
final int length;
HomeState copyWith({
String name,
int length,
}) {
return HomeState(
name: name ?? this.name,
length: length ?? this.length,
);
}
}
また、ポイントとしてcopyWith
メソッドがあります。
Reducerはこれを使って新しいStateを作ることになります。
全て一から新しいStateを作るのではなく、今のStateから変更しないものはコピーして、変更する値だけ変更するという形をとっています。
name ?? this.name
の部分が、copyWith
メソッドに値が渡されなかったものは今の値を使うという実装です。
Reducer
Reducerは今のStateとdispatchされたActionを受け取って新しいStateを返します。
上に書いた通り、copyWith
メソッドを使って、全て一から新しいStateを作るのではなく、今のStateから変更しないものはコピーして、変更する値だけ変更するという形をとっています。
final Reducer<HomeState> homeReducer = combineReducers<HomeState>([
TypedReducer(_updateName),
]);
HomeState _updateName(HomeState state, UpdateName action) {
return state.copyWith(name: action.name);
}
感想
デメリットもないことはないですが、総合的に判断してReduxを採用して良かったと今のところ思っています。
Thunkの採用に関しては微妙なところで、チーム内でも議論が出ています。
私は元々iOSエンジニアで、iOSをやっていたときもReduxを採用したアプリを開発していたことがありますが、少なくともiOSよりはFlutterの方が相性はいいなと思いました。
iOSはUIKitの問題でStateが変わった時の差分更新の相性が悪かったので、Webフロントのアーキテクチャをそのままアプリに持ってくると不都合なことは起きるよねというのが正直な感想でしたが、FlutterのViewはWidgetの木構造でできていて、HTMLと似ている部分があるのでReduxがはまっている感があります。
ただ、Flutterでのアプリ開発はまだReduxでしかやったことがなく、他のアーキテクチャと比較ができていないので、別の機会に他のアーキテクチャを試してみたいと思います。
スライドもどぞ