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`へアクセスできる。

```dart
// Storeを生成し
final store = const Store();

// StoreProviderをルートに置く
runApp(new StoreProvider(
  store: store,
  child: new MaterialApp(... your app ...),
));
```

基本的には`Store`は最上位にひとつだけでよいと思われるが、複数の`Store`や`StoreProvider`を使う余地は残している。

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

```dart
final store = Store.of(context);

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

## Channel

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

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

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

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

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

```dart
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してしまうとバグになるので、一元管理しておくのが望ましい。

ライブラリ側の機能ではないが、アプリ側で以下のように実装しておくとよい。

```dart
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側で以下のように使うことができる。

```dart
final channel = Channels.todoList();

final store = Store.of(context);
final todoList = channel.get(store).value;
```

## ChannelBuilder

`Store`の特定の`Channel`を監視する`Builder`。チャンネルに変更があった場合は`builder`が呼び出されるので、Widgetは最新の状態に応じて更新される。

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

```dart
@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なので、パラメータを持たせることも出来る。

```dart
@immutable
class TodoDetailLoadAction extends Action {
  const TodoDetailLoadAction(this.id);
  
  final int id;
  
  @override
  Future<void> run(Store store) {
    // idを使ってAPI呼び出しなど
  }
}
```

なお、Actionを呼び出す側は以下のようになる。

```dart
final store = Store.of(context);
store.action(new TodoListLoadAction());
```

※ディスパッチしていないのでdispatchという名前はやめた。何がいいだろうか？ run, do, callとか？

## データの寿命

データは参照カウント方式で管理される。

`Channel`を監視している`ChannelBuilder`が存在する間は、データは残っている。監視がなくなった場合は（デフォルトの設定では）データは破棄される（`Store`から参照がなくなり、どこからも辿れなくなる。いずれGCされる）。

データによっては、画面ごとではなくアプリ全体で使われる性質のものもある。こういったデータのために、参照されているかどうかにかかわらず保持し続ける設定ができる。以下のように`Channel`の設定に`volatile: false`としておくと、そのチャンネルを経由して設定されたデータは不揮発となり、参照しているWidgetの有無によらず残る。

```dart
class Channels {
  static Channel<ApiSession> apiSession() {
    return new Channel<ApiSession>("todo-list", volatile: false);
  }
}
```

### 一覧から詳細

一覧画面でデータのリストを取得し、それを個別のデータを表示するWidgetに渡す場合。

一覧画面は「リスト」を監視しており、個別のデータは監視していないので、個別の画面が表示される前に`Store`に保存しても揮発してしまう。このため、`Store`経由ではなくデータを直接渡す必要がある。

個別のデータが不変であれば、単に`StatelessWidget`で表示すればよいだけだが、変更に応答するために`ChannelBuilder`を使いたい場合は`Channel`を使う必要がある。

よって、以下のような実装になる。

```dart
// 一覧のListView
return new ListView.builder(
  itemBuilder: (BuildContext context, int index) {
    if (index >= items.length) {
      return null;
    }
    // 個別のWidget生成にはデータを直接渡す
    return _buildListItem(context, items[index]);
  },
);
```

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