LoginSignup
23
12

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-06-03

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

23
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
12