概要
flutterの入門記事が増えているけれど、本格的に使うためにはアーキテクチャを悩むと思います。
flutterはReactNativeにインスパイアされているので、flux/reduxと相性がいいはずです。
そこで、flutterでのreduxライブラリflutter_reduxでのflutterアプリの書き方を記事に書いておきます。
flutter_reduxでは、flutterのみでの実装方法とかなり変わる部分があるので注意が必要です。
Redux
Reduxについては良記事がたくさんあるのでここでは簡単に書いておきます。
ただ、一つ言えるのは、複雑に見えて(使う分には)非常に簡単なアーキテクチャだと覚えておいてください。
Reduxの3原則
まず、Reduxの3原則がありますが、こんな感じです。
- 状態(State)はアプリケーション内で一つだけ。ツリー状態で保持する。
Reduxでは、ルートのStateはAppStateと慣習的につけられます。
ツリー状態ということは、flutterではAppStateでコンポジットして持つということです。
- 状態(State)はImmutableにする。
そのままです。flutterでは、@immutable
をつけ、constコンストラクタを用意することで実現します。
- 状態(State)は副作用がない純粋関数(Reducer)を通して新たな状態(State)を返す。
これもそのままです。Immutableなので当たり前な感じもします。
状態変更を指示する情報(Action)の種類によって呼ばれるreducer関数を用意します。
Data Flow
3原則を踏まえ、Reduxではデータの流れは一方向なアーキテクチャです。
よく図で書いてありますが、簡単に言うと処理を投げて、結果はListenerで貰う感じのアーキテクチャだとここでは思えばいいでしょう。
Reduxでの構造と処理の流れ
Reduxの特徴がData Flowなので仕方ないですが、処理の流れと構造を見た方が簡単に理解できます。
まずは3原則で出た用語の概略。
State
- シングルトン
- Immutable
- 唯一の状態保持クラス
- 情報をツリー構造で保持。つまりはコンポジット
- (ちなみに本家ではjsonなツリー構造)
- 慣習的にルートはAppState
Action
- State変更を指示するクラス。enumでも良い。
- flutterでは、ただの箱オブジェクト。
- (ちなみに本家ではtypeプロパティ=「どういった操作か」の文字列orシンボルが必須なjson構造)
Reducer
- 状態変更ができる唯一の場所
- 引数が、変更前のState + Action
- 戻り値が、最新のState
- Actionごとに存在する関数
- 副作用なしの純粋関数
Store
3原則では出てきませんでしたが、Reduxではこれが中心となるクラスになり、このクラスのメソッドを次々と呼び出します。
- シングルトン
- インスタンス時にAppStateとReducerを保持する ①
- store.dispatch(action)メソッドで変更指示する ②
- store.onChange.listen(listener)メソッドでUIに状態変更を知らせる。③
- 実際にはflutter_reduxではこれを呼ばない仕組み(StoreProvider)で実装していく
- 本家のsubscribe()メソッドに相当
- 対になるonPause,onResume,onCancelメソッドで後処理をする。④
処理の流れは起動時に上記の①が起こり、ユーザ入力などで②が呼ばれます。
②を呼ぶと引数のActionの型によって呼ばれるreducerが決まり、新しいStateができてそれをStoreが保持し直す。
同時に、Stateが変わったことを③で購読しているUIに知らせるListenerが呼びだされます。
reduxは一見すると難しく感じるけど、単純にするとこれだけです。
Middleware
Reducerの実行前後に処理を入れることができる拡張ポイントです。
複数のMidlewareが登録可能で、チェインに処理が繋がります。
一般的なMidlewareの概念と同様ですが、reduxの場合は
- 副作用のある処理(非同期処理)を書くところ
としても利用されます。
Flutterでの実装
github apiでGithubのレポジトリを検索して一覧で表示するアプリを作ってみます。
ソースは以下に置いてあります。
https://github.com/ko2ic/spike_redux_flutter
ライブラリ
まずは、reduxとflutter_reduxを入れます。
redux: "^3.0.0"
flutter_redux: "^0.5.0"
State
まずは唯一存在する状態クラスを作成します。
@immutable
アノテーションをつけてimmutableクラスと宣言しています。
また、定数コンストラクタにしています。
他にFavoriteStateなど必要なStateクラスも作成します。
@immutable
class AppState {
final bool isLoading;
final List<RepoEntity> repoList;
final FavoriteState favoriteState;
const AppState(
{
this.isLoading = false,
this.repoList = const [],
this.favoriteState = const FavoriteState(favoriteList: const []),
});
factory AppState.loading() => const AppState(isLoading: true);
AppState copyWith({
bool isLoading,
List<RepoEntity> repoList,
FavoriteState favoriteState,
}) {
return new AppState(
isLoading: isLoading ?? this.isLoading,
repoList: repoList ?? this.repoList,
favoriteState: favoriteState ?? this.favoriteState
);
}
}
Store
ライブラリでStoreクラスが用意されているので、それをインスタンスします。
reduderやmiddleware、初期状態も指定できます。
void main() {
runApp(new MyApp());
}
class MyApp extends StatelessWidget {
final store = new Store<AppState>(
appReducer,
initialState: new AppState.loading(),
middleware: [fetchRepoListMiddleware],
);
・・・
Reducer
Storeインスタンス時に引数に渡す appReducer
関数を作成します。
DartなのでJavaなどのようにclassを作る必要がないです。
AppState appReducer(AppState state, action) {
return new AppState(
isLoading: loadingReducer(state.isLoading, action),
repoList: repoListReducer(state.repoList, action),
favoriteState: favoritesReducer(state.favoriteState, action),
);
}
以下は、LoadingAction
や LoadCompleteAction
が呼ばれた時のreducerです。
LoadingActionが呼ばれたら、true、LoadCompleteActionが呼ばれたら、falseを返します。
ライブラリで用意されているcombineReducersで一つに纏めます。
final loadingReducer = combineReducers<bool>([
new TypedReducer<bool, LoadingAction>( (state, action) => true),
new TypedReducer<bool, LoadCompleteAction>((state, action) => false),
]);
以下は、FetchRepoListSucceededAction
や FetchRepoListFailedAction
が呼ばれた時のreducerです。
final repoListReducer = combineReducers<List<RepoEntity>>([
new TypedReducer<List<RepoEntity>, FetchRepoListSucceededAction>(_setLoadedRepoList),
new TypedReducer<List<RepoEntity>, FetchRepoListFailedAction>(_setNoRepoList),
]);
List<RepoEntity> _setLoadedRepoList(List<RepoEntity> repoList, FetchRepoListSucceededAction action) {
return action.items;
}
List<RepoEntity> _setNoRepoList(List<RepoEntity> repoList, FetchRepoListFailedAction action) {
return [];
}
以下は、Githubレポジトリの一覧の一つのアイテムをお気に入りへOn/OFFした時に呼ばれるreduderです。
引数に以前の状態がくるので、それを見て新しい状態を返します。
final favoritesReducer = combineReducers<FavoriteState>([
new TypedReducer<FavoriteState, FavoriteAction>(_setFavorite),
]);
FavoriteState _setFavorite(FavoriteState state, FavoriteAction action) {
var newList = new List<RepoEntity>();
newList.addAll(state.favoriteList);
if (newList.contains(action.target)){
newList.remove(action.target);
}else{
newList.add(action.target);
}
return new FavoriteState(favoriteList: newList);
}
Action
State変更指示のためのオブジェクトです。dispatchの引数の型は、dynamicなのでenumでも良いです。
ここではclassで作ってます。
class LoadCompleteAction {}
class LoadingAction {}
class FetchRepoListAction {
final String keyword;
const FetchRepoListAction(this.keyword);
}
class FetchRepoListSucceededAction {
final List<RepoEntity> items;
const FetchRepoListSucceededAction(this.items);
}
class FetchRepoListFailedAction {
final Exception error;
const FetchRepoListFailedAction(this.error);
}
class FavoriteAction{
final List<RepoEntity> favorites;
final RepoEntity target;
const FavoriteAction(this.favorites, this.target);
}
Middleware
非同期処理を書きます。GithubRepositoryImplは、Github Apiを呼び出しています。
(本来は単体テストをしやすいようにGithubRepositoryImplのインターフェイスを渡すようにした方がいいです。)
全てのdispatchでこのメソッドが呼ばれるので、actionの型で呼び出すかを判断します。
void fetchRepoListMiddleware(Store<AppState> store, action, NextDispatcher next) {
if (action is FetchRepoListAction) {
final repository = new GithubRepositoryImpl();
next(new LoadingAction());
repository.fetch(action.keyword).then((SearchResultDto dto) {
next(new FetchRepoListSucceededAction(dto.items));
}).catchError((Exception error) {
next(new FetchRepoListFailedAction(error));
}).whenComplete(() => next(new LoadCompleteAction()));
}
next(action);
}
Repository内部で以下のHttpClientを呼び出しています。
class GithubHttpClient {
static const _BASE_URL = 'https://api.github.com';
Future<SearchResultDto> fetch(String freewore) async {
final response = await http.get("$_BASE_URL/search/repositories?q=$freewore&page=1");
final responseJson = json.decode(response.body);
return new SearchResultDto.fromJson(responseJson);
}
}
UI
あとは、UIでリスナーを購読/解除するのが通常のreduxですが、flutter_reduxは違う仕組みを持っています。
自分で購読/解除する必要がありません。
StoreProviderとStoreConnector
StoreProviderもStoreConnectorも親がWidgetなので、Widgetの場所に記述できます。
StoreProviderは、アプリケーションのルートWidgetにします。
StoreProviderはインスタンス時に設定されたstoreを子孫のwidgetに提供できるようにするためのクラスです。
後述するStoreBuilderやStoreConnectorを通じて、storeを取得します。
StoreBuilderでは、onInit
や onDispose
メソッドを指定できます。
この例では、FetchRepoListActionをdispatchしています。(これはGithubレポジトリ一覧をAPIを呼び出して取得するアクションです。)
ただし、StoreBuilderを使うとStoreの変更があるたびにこのbuilerメソッドが呼ばれてしまいます。
なので、StoreConnectorを利用し、distincct:true
にし、converter
では、true(この値はなんでも良い。必須プロパティのために無理やり値を設定しているだけ)を設定します。
class MyApp extends StatelessWidget {
final store = ・・・
@override
Widget build(BuildContext context) {
return new StoreProvider(
store:store,
child: new MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primaryColor: Colors.green,
),
//home:
//StoreBuilder<AppState>(
//onInit: (store) => store.dispatch(new FetchRepoListAction("ko2")),
//builder: (context, store) {
// return new GithubListWidget(store);
//},
home: StoreConnector<AppState, bool>(
distinct: true,
onInit: (store) => store.dispatch(FetchRepoListAction("ko2")),
converter: (store) => true,
builder: (context, _) {
return GithubListPage();
},
)
)
);
}
}
StoreConnector
上記でインスタンスしたGithubListWidgetがStatefulWidgetで、それのStateが以下のGithubListPageです。
ここで利用しているStoreConnectorがあるために購読/解除が不要になります。
converterに指定するものは、戻り値がVieModelで引数がStoreな関数を指定します。
builderでは、converterの戻り値のViewModelが渡されます。
どういうことかというと、Stateが変わるたびに(通常のflutterと同じでbuild()メソッドが呼ばれ、)converterが呼ばれ、変更後のStateからViewModelが生成されます。
そのViewModelを使ってWidgetを再生成しているのです。
class GithubListPage extends State<GithubListWidget> {
・・・
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: searchBar.build(context),
key: _scaffoldKey,
body: new StoreConnector(
converter: _ViewModel.fromStore,
builder: (context, viewModel) {
var list = viewModel.repoList;
return new LoadingWidget(onCompleted: () {
return new ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: list.length,
itemBuilder: (context, i) {
return _buildRow(list[i], viewModel);
});
});
}),
);
}
Widget _buildRow(RepoEntity entity, _ViewModel viewModel) {
・・・
}
}
class _ViewModel {
final bool loading;
final List<RepoEntity> repoList;
final Function(RepoEntity) onFavoriteChanged;
final List<RepoEntity> favorites;
_ViewModel({
@required this.loading,
@required this.repoList,
@required this.onFavoriteChanged,
@required this.favorites
});
static _ViewModel fromStore(Store<AppState> store){
return new _ViewModel(
loading: store.state.isLoading,
repoList: store.state.repoList,
onFavoriteChanged: (entity) {
store.dispatch(new FavoriteAction(store.state.favoriteState.favoriteList, entity));
},
favorites: store.state.favoriteState.favoriteList
);
}
}
このStoreConnectorが利用する部分が大きく通常のFlutterやReduxと違うところです。
ちなみに同じ処理を普通のflutterだけで書くと以下になります。
class GithubListPageState extends State<GithubListPage> {
List<RepoEntity> _repos = [];
bool _isLoading = false;
・・・
@override
void initState() {
super.initState();
_fetch("ko2");
}
_fetch(String freeword) {
_setLoading(true);
new GithubRepositoryImpl()
.fetch(freeword)
.then((s) => _setRepos(s.items))
.whenComplete(() => _setLoading(false));
}
_setLoading(bool isLoading) {
setState(() => this._isLoading = isLoading);
}
_setRepos(List<RepoEntity> repos) {
setState(() => _repos = repos);
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: searchBar.build(context),
key: _scaffoldKey,
body: new LoadingWidget(
isLoading: this._isLoading,
onCompleted: () {
return new ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _repos.length,
itemBuilder: (context, i) {
return _buildRow(_repos[i]);
});
},
),
);
}
Widget _buildRow(RepoEntity entity) {
・・・
}
}
見るとわかりますが、reduxの方が書く量が相当増えます。
ただし、reduxを使わない場合は、_isLoading
や_repos
などの状態や_fetch
処理などがWidgetクラスに容易に漏れます。
こうなると単体テストが難しくなります。
単純なアプリなら、reduxを使わない方が良いですが、複雑な状態を持つようになるとreduxを使った方がわかりやすくなります。
公式サイトにもそのように書いています。
このトレードオフを考えて、違うアーキテクチャでやるかを決めれば良いと思います。
reduxを利用しない場合はこちらの記事が参考になると思います。
状態管理の方法として、以下の方法が説明されています。
- InheritedWidget
- Business Logic ComponentとStream
StatefulWidgetについて
Reduxを使う場合は、StatelessWidgetだけで良いです。
ただし、気をつけないと子孫のビルドが動き予期せぬ動作をします。
以下になるように実装しましょう。
- StoreBuilderを使わない。
- StoreConnectorを使う。
-
distinct: true
にする - ViewModelの
operator ==
をオーバーライドする
理由は、StoreBuilderは常にStore全体をリッスンします。
Storeが変更されるたびに全てのbuildが始まってしまうからです。
flutter_reduxを使うと「StatefulWidgetを使わなくていいのでは?」と思えます。
状態はflutter_reduxが管理するので、Stateクラスを継承して、setState()メソッドを呼ぶ必要がないからです。
自分も最初は、StatelessWidgetだけで実装していました。
途中まではうまくいっていましたが、一覧のアイテムを操作する処理、例えば、「白いFavoriteアイコンをクリックしたら赤いアイコンに変わる」をした時に、クリックしたら一覧の一番上に戻ってしまうバグになりました。
これをStatefulWidgetにしただけで、現在クリックした場所に留まるようになりました。
なので、変更があるWidgetの場合は今まで通り、StatefulWidgetを使うことをお勧めします。
単体テスト
Flutter + Reduxでは単体テストも非常に簡単に書けます。
単体テストについては、こちらの記事を参考にしてください。
Flutterの単体テスト全般とReduxの単体テストについても触れています。