👻

Flutter における Flux アーキテクチャの実装について考え中

2020/10/01に公開

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


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