Flutter における Flux アーキテクチャの実装について考え中
Flutterで書いているアプリに、Fluxアーキテクチャを導入しようと思っています。
最初はReduxを検討して https://github.com/brianegan/flutter_redux を使おうとしました。しかし、このライブラリだと、状態のあらゆる更新において、すべてのRedux系Widgetに対してイベントが通知されてしまいます。少なくともconverterの呼び出し、distinctしていなければbuildも呼び出されてしまいます。もちろん、その結果構築されるWidgetは正しいのですが、処理のコストが気になりました。
そこで、以下のような感じで実装するのはどうか、というのを考えています。ライブラリ部分はまだ架空で、使う側のアプリがどんなふうになるかを書きます。
Store
アプリ内でひとつ。
- 状態を持つ。
Channel
を通じて状態の更新や取得の手段を提供する。 -
Action
を実行する
※Channel
は後述。
StoreProvider
StoreProvider
によって、各Widgetに対してStore
へのアクセスを提供する。StoreProvider
はFlutterで各WidgetがStore
にアクセスできるようにするためのInheritedWidget
。これをWidget treeの最上位に置く。こうすることで、アプリ内のすべてのWidgetはStore
へアクセスできる。
// Storeを生成し
final store = const Store();
// StoreProviderをルートに置く
runApp(new StoreProvider(
store: store,
child: new MaterialApp(... your app ...),
));
基本的にはStore
は最上位にひとつだけでよいと思われるが、複数のStore
やStoreProvider
を使う余地は残している。
Widget側でStore
を使いたい場合は以下のようにして参照を得られる。
final store = Store.of(context);
// もしくは
final store = StoreProvider.of<Store>(context);
Channel
WidgetがStore
の更新を監視してしまうと、あらゆる変更でWidgetがbuildされてしまう。そこで、Store
の配下にあるChannel
というものをWidgetは監視する。Channel
はStore
内に保持されている状態の一部に対するアクセサといえる。
Channel
にはすべて名前がついており、これによって違うデータをやり取りできる。
final channel = new Channel<String>("channel-name");
// Storeを渡してデータを取得する
final value = channel.get(store);
final yourString = value.value;
Channel<V>#get
はValue<V>
を返す。このクラスは、value
とerror
を持っており、エラー時のハンドリングもできるようになっている。
final value = channel.get(store);
if (value.error != nil) {
return new Text("エラー");
} else if (value.value == nil) {
return new Text("処理中");
}
return _buildYourWidget(value.value);
Channels
Channel
は名前によって識別されるが、これをtypoしてしまうとバグになるので、一元管理しておくのが望ましい。
ライブラリ側の機能ではないが、アプリ側で以下のように実装しておくとよい。
class Channels {
static Channel<TodoList> todoList() {
return new Channel<TodoList>("todo-list");
}
static Channel<Todo> todo(int id) {
return new Channel<Todo>("todo-${id}");
}
}
こうしておくと、Widget側で以下のように使うことができる。
final channel = Channels.todoList();
final store = Store.of(context);
final todoList = channel.get(store).value;
ChannelBuilder
Store
の特定のChannel
を監視するBuilder
。チャンネルに変更があった場合はbuilder
が呼び出されるので、Widgetは最新の状態に応じて更新される。
return new ChannelBuilder<TodoList>(
// このWidgetが監視するチャンネル
channel: Channels.todoList(),
// Widgetの初期化(State.initState)で呼び出される
onInit: (Store store, Value<TodoList> value) {
if (value.value == null) {
// 初期データがない場合にAPIなどの読み込みのActionを発行する
store.action(const TodoListLoadAction());
}
},
// チャンネルに更新があった場合に呼び出されてWidgetを生成する
// もちろん最初のbuild時にも呼び出される
builder: (BuildContext context, Value<TodoList> value) {
if (value.error != null) {
return new Text("エラー");
} else if (value.value == null) {
return new Text("読み込み中");
}
// データを使ってWidgetを作る
return new ListView.builder(... using value.value ...);
},
);
Action
WidgetからはStore
を更新せず、操作やイベントに対応するAction
を発行する。
Action
を継承、run
メソッドをオーバーライドして、アプリでそれぞれのアクションを実装する。引数で渡されるStore
を使うなどして、アプリの状態を更新する。
class TodoListLoadAction extends Action {
Future<void> run(Store store) {
return apiFetchTodoList().then<void>((TodoList todoList) {
// APIから得られたデータをChannelを経由してStoreに保存する
// このあとで関連するWidgetは更新される
Channels.todoList().set(store, todoList);
}).catchError((error) {
// エラーも通知できる
Channels.todoList().error(store, error);
});
}
}
※Flux/ReduxでいうActionCreator, Action, Reducer, Middlewareがまとまったものといえる。状態Store
がmutableである本案では、これらをとくに分割せずにAction
とする。
Action
はclassなので、パラメータを持たせることも出来る。
class TodoDetailLoadAction extends Action {
const TodoDetailLoadAction(this.id);
final int id;
Future<void> run(Store store) {
// idを使ってAPI呼び出しなど
}
}
なお、Actionを呼び出す側は以下のようになる。
final store = Store.of(context);
store.action(new TodoListLoadAction());
※ディスパッチしていないのでdispatchという名前はやめた。何がいいだろうか? run, do, callとか?
データの寿命
データは参照カウント方式で管理される。
Channel
を監視しているChannelBuilder
が存在する間は、データは残っている。監視がなくなった場合は(デフォルトの設定では)データは破棄される(Store
から参照がなくなり、どこからも辿れなくなる。いずれGCされる)。
データによっては、画面ごとではなくアプリ全体で使われる性質のものもある。こういったデータのために、参照されているかどうかにかかわらず保持し続ける設定ができる。以下のようにChannel
の設定にvolatile: false
としておくと、そのチャンネルを経由して設定されたデータは不揮発となり、参照しているWidgetの有無によらず残る。
class Channels {
static Channel<ApiSession> apiSession() {
return new Channel<ApiSession>("todo-list", volatile: false);
}
}
一覧から詳細
一覧画面でデータのリストを取得し、それを個別のデータを表示するWidgetに渡す場合。
一覧画面は「リスト」を監視しており、個別のデータは監視していないので、個別の画面が表示される前にStore
に保存しても揮発してしまう。このため、Store
経由ではなくデータを直接渡す必要がある。
個別のデータが不変であれば、単にStatelessWidget
で表示すればよいだけだが、変更に応答するためにChannelBuilder
を使いたい場合はChannel
を使う必要がある。
よって、以下のような実装になる。
// 一覧のListView
return new ListView.builder(
itemBuilder: (BuildContext context, int index) {
if (index >= items.length) {
return null;
}
// 個別のWidget生成にはデータを直接渡す
return _buildListItem(context, items[index]);
},
);
Widget _buildListItem(BuildContext context, Item item) {
return new ChannelBuilder<Item>(
// 個別のデータのChannel
channel: Channels.item(item.id),
onInit: (Store store, Value<Item> value) {
// Action内で該当channelにsetされる
store.action(new ItemLoadedAction(item));
},
builder: (BuildContext context, Value<Item> value) {
return new Text(value.value?.name ?? "loading...");
},
);
}
原則
- Widgetのbuildでは、Store/Channelのget/setをしてはならない。必ずChannelBuilderを経由する。
- build以外のWidgetの処理(ユーザー操作のハンドラなど)では、getは行ってよい。これは、状態に応じて画面遷移やActionなどが変わるため。setはしてはならない。
まとめ
というわけで、WidgetがActionを発行し、Actionが処理を実行し、Storeが更新され、Channelを経由してWidgetが更新される、というFlux的な流れは出来るように思える。
この記事はQiitaの記事をエクスポートしたものです
Discussion