Flutter

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は最上位にひとつだけでよいと思われるが、複数のStoreStoreProviderを使う余地は残している。

Widget側でStoreを使いたい場合は以下のようにして参照を得られる。

final store = Store.of(context);

// もしくは
final store = StoreProvider.of<Store>(context);


Channel

WidgetがStoreの更新を監視してしまうと、あらゆる変更でWidgetがbuildされてしまう。そこで、Storeの配下にあるChannelというものをWidgetは監視する。ChannelStore内に保持されている状態の一部に対するアクセサといえる。

Channelにはすべて名前がついており、これによって違うデータをやり取りできる。

final channel = new Channel<String>("channel-name");

// Storeを渡してデータを取得する
final value = channel.get(store);
final yourString = value.value;

Channel<V>#getValue<V>を返す。このクラスは、valueerrorを持っており、エラー時のハンドリングもできるようになっている。

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を使うなどして、アプリの状態を更新する。

@immutable

class TodoListLoadAction extends Action {
@override
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なので、パラメータを持たせることも出来る。

@immutable

class TodoDetailLoadAction extends Action {
const TodoDetailLoadAction(this.id);

final int id;

@override
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的な流れは出来るように思える。