システム開発部のTです。
以前、Fluxで組んでいるアプリがあり、コードも分かりやすい感じでまとめられていたのを思い出しました。
そこで、FlutterでもFluxで組んでみたいと思い立って、今回挑戦してみました。
「Flutter Flux」で検索かけてみると、結構検索結果として表示されているのですが、その中で以下のものが気になりました。
https://github.com/google/flutter_flux
GoogleがFlutter用のFluxパターンを作っていた????
非常に興味深い!
ということで、本件では上記のFluxパターンを元にして実装してみましたので、
そのレポートを書いていきたいと思います。
実装イメージ
こんな感じの動きになります。

開発準備
プロジェクトの生成
以下のコマンドを実行し、プロジェクトを生成してください。
(以下、helloworldにしていますが、任意のプロジェクト名にしてください。)
$ flutter create helloworld
(※Flutterの開発環境は構築済みの前提で進めていきます。)
Fluxのテンプレートクラスの生成
githubから持ってくればいいんじゃないの???
と思った方もいるかと思いますが、そもそも最終更新日が3年前くらいなので、
null safetyにも対応しておらず、そのままでは利用できないので、加工していきます。
以下、加工後のファイルの中身になります。
action.dart
// Copyright 2015 Workiva Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import 'dart:async'; typedef void OnData<T>(T event); /// A command that can be dispatched and listened to. /// /// An [Action] manages a collection of listeners and the manner of /// their invocation. It *does not* rely on [Stream] for managing listeners. By /// managing its own listeners, an [Action] can track a [Future] that completes /// when all registered listeners have completed. This allows consumers to use /// `await` to wait for an action to finish processing. /// /// var asyncListenerCompleted = false; /// action.listen((_) async { /// await new Future.delayed(new Duration(milliseconds: 100), () { /// asyncListenerCompleted = true; /// }); /// }); /// /// var future = action(); /// print(asyncListenerCompleted); // => 'false' /// /// await future; /// print(asyncListenerCompleted). // => 'true' /// /// Providing a [Future] for listener completion makes actions far easier to use /// when a consumer needs to check state changes immediately after invoking an /// action. /// class FluxAction<T> implements Function { List<OnData<T>> _listeners = <OnData<T>>[]; /// Dispatch this [FluxAction] to all listeners. If a payload is supplied, it will /// be passed to each listener's callback, otherwise null will be passed. Future<List<dynamic>> call({required T payload}) { // Invoke all listeners in a microtask to enable waiting on futures. The // microtask queue is emptied before the event loop continues. This ensures // synchronous listeners are invoked in the current tick of the event loop // without being scheduled at the back of the event queue. A Dart [Stream] // behaves in a similar fashion. // // Performance benchmarks over 10,000 samples show no performance // degradation when dispatching actions using this action implementation vs // a [Stream]-based action implementation. At smaller sample sizes this // implementation slows down in comparison, yielding average times of 0.1 ms // for stream-based actions vs. 0.14 ms for this action implementation. return Future.wait<dynamic>( _listeners.map( (OnData<T> l) => new Future<dynamic>.microtask(() => l(payload)) ), ); } /// Cancel all subscriptions that exist on this [FluxAction] as a result of /// [listen] being called. Useful when tearing down a flux cycle in some /// module or unit test. void clearListeners() => _listeners.clear(); /// Supply a callback that will be called any time this [FluxAction] is /// dispatched. A payload of type [T] will be passed to the callback if /// supplied at dispatch time, otherwise null will be passed. Returns an /// [ActionSubscription] which provides means to cancel the subscription. ActionSubscription listen(OnData<T> onData) { _listeners.add(onData); return new ActionSubscription(() => _listeners.remove(onData)); } } typedef void _OnCancel(); /// A subscription used to cancel registered listeners to an [FluxAction]. class ActionSubscription { final _OnCancel _onCancel; ActionSubscription(this._onCancel); /// Cancel this subscription to an [FluxAction] void cancel() { if (_onCancel != null) { _onCancel(); } } }
store.dart
// Copyright 2015 Workiva Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import 'dart:async'; import 'action.dart'; /// A `Store` is a repository and manager of app state. This class should be /// extended to fit the needs of your application and its data. The number and /// hierarchy of stores is dependent upon the state management needs of your /// application. /// /// General guidelines with respect to a `Store`'s data: /// - A `Store`'s data should not be exposed for direct mutation. /// - A `Store`'s data should be mutated internally in response to [Action]s. /// - A `Store` should expose relevant data ONLY via public getters. /// /// To receive notifications of a `Store`'s data mutations, `Store`s can be /// listened to. Whenever a `Store`'s data is mutated, the `trigger` method is /// used to tell all registered listeners that updated data is available. /// /// In a typical application using `w_flux`, a [FluxComponent] listens to /// `Store`s, triggering re-rendering of the UI elements based on the updated /// `Store` data. class FluxStore { /// Construct a new [FluxStore] instance. FluxStore() { _streamController = new StreamController<FluxStore>(); _stream = _streamController.stream.asBroadcastStream(); } /// Construct a new [FluxStore] instance with a transformer. /// /// The standard behavior of the "trigger" stream will be modified. The /// underlying stream will be transformed using [transformer]. /// /// As an example, [transformer] could be used to throttle the number of /// triggers this [FluxStore] emits for state that may update extremely frequently /// (like scroll position). FluxStore.withTransformer(StreamTransformer<FluxStore, dynamic> transformer) { _streamController = new StreamController<FluxStore>(); // apply a transform to the stream if supplied _stream = _streamController.stream.transform<dynamic>(transformer).asBroadcastStream() as Stream<FluxStore>; } /// Stream controller for [_stream]. Used by [trigger]. late StreamController<FluxStore> _streamController; /// Broadcast stream of "data updated" events. Listened to in [listen]. late Stream<FluxStore> _stream; void dispose() { _streamController.close(); } /// Trigger a "data updated" event. All registered listeners of this `Store` /// will receive the event, at which point they can use the latest data /// from this `Store` as necessary. /// /// This should be called whenever this `Store`'s data has finished mutating in /// response to an action. void trigger() { _streamController.add(this); } /// A convenience method for listening to an [action] and triggering /// automatically. The callback doesn't call return, so the return /// type of onAction is null. void triggerOnAction<T>(FluxAction<T> action, {dynamic onAction(T payload)?}) { if (onAction != null) { action.listen((T payload) async { await onAction(payload); trigger(); }); } else { action.listen((dynamic _) { trigger(); }); } } /// A convenience method for listening to an [action] and triggering /// automatically once the callback returns true when it completes. /// /// [onAction] will be called every time [action] is dispatched. /// If [onAction] returns a [Future], [trigger] will not be /// called until that future has resolved and the function returns either /// void (null) or true. void triggerOnConditionalAction<T>( FluxAction<T> action, FutureOr<bool> onAction(T payload)) { action.listen((dynamic payload) async { // Action functions must return bool, or a Future<bool>. dynamic result = onAction(payload); bool wasChanged; if (result is Future) { wasChanged = await result; } else { wasChanged = result; } if (wasChanged) { trigger(); } }); } /// Adds a subscription to this `Store`. /// /// Each time this `Store` triggers (by calling [trigger]), indicating that /// data has been mutated, [onData] will be called. StreamSubscription<FluxStore> listen(void onData(FluxStore event), {Function? onError, void onDone()?, bool? cancelOnError}) { return _stream.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); } }
store_watcher.dart
// Copyright 2016 The Chromium Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter/foundation.dart'; import 'store.dart'; /// Signature for a function the lets the caller listen to a store. typedef FluxStore ListenToStore(StoreToken token, {ValueChanged<FluxStore>? onStoreChanged}); /// A widget that rebuilds when the [FluxStore]s it is listening to change. abstract class StoreWatcher extends StatefulWidget { /// Creates a widget that watches stores. StoreWatcher({ required Key key }) : super(key: key); /// Override this function to build widgets that depend on the current value /// of the store. @protected Widget build(BuildContext context, Map<StoreToken, FluxStore> stores); /// Override this function to configure which stores to listen to. /// /// This function is called by [StoreWatcherState] during its /// [State.initState] lifecycle callback, which means it is called once per /// inflation of the widget. As a result, the set of stores you listen to /// should not depend on any constructor parameters for this object because /// if the parent rebuilds and supplies new constructor arguments, this /// function will not be called again. @protected void initStores(ListenToStore listenToStore); @override StoreWatcherState createState() => new StoreWatcherState(); } /// State for a [StoreWatcher] widget. class StoreWatcherState extends State<StoreWatcher> with StoreWatcherMixin<StoreWatcher> { final Map<StoreToken, FluxStore> _storeTokens = <StoreToken, FluxStore>{}; @override void initState() { widget.initStores(listenToStore); super.initState(); } /// Start receiving notifications from the given store, optionally routed /// to the given function. /// /// The default action is to call setState(). In general, you want to use the /// default function, which rebuilds everything, and let the framework figure /// out the delta of what changed. @override FluxStore listenToStore(StoreToken token, {ValueChanged<FluxStore>? onStoreChanged}) { final FluxStore store = super.listenToStore(token, onStoreChanged: onStoreChanged); _storeTokens[token] = store; return store; } @override Widget build(BuildContext context) { return widget.build(context, _storeTokens); } } /// Listens to changes in a number of different stores. /// /// Used by [StoreWatcher] to track which stores the widget is listening to. mixin StoreWatcherMixin<T extends StatefulWidget> on State<T>{ final Map<FluxStore, StreamSubscription<FluxStore>> _streamSubscriptions = <FluxStore, StreamSubscription<FluxStore>>{}; /// Start receiving notifications from the given store, optionally routed /// to the given function. /// /// By default, [onStoreChanged] will be called when the store changes. @protected FluxStore listenToStore(StoreToken token, {ValueChanged<FluxStore>? onStoreChanged}) { final FluxStore store = token._value; _streamSubscriptions[store] = store.listen(onStoreChanged ?? _handleStoreChanged); return store; } /// Stop receiving notifications from the given store. @protected void unlistenFromStore(FluxStore store) { _streamSubscriptions[store]?.cancel(); _streamSubscriptions.remove(store); } /// Cancel all store subscriptions. @override void dispose() { final Iterable<StreamSubscription<FluxStore>> subscriptions = _streamSubscriptions.values; for (final StreamSubscription<FluxStore> subscription in subscriptions) subscription.cancel(); _streamSubscriptions.clear(); super.dispose(); } void _handleStoreChanged(FluxStore store) { // TODO(abarth): We cancel our subscriptions in [dispose], which means we // shouldn't receive this callback when we're not mounted. If that's the // case, we should change this check into an assert that we are mounted. if (!mounted) return; setState(() { }); } } /// Represent a store so it can be returned by [StoreListener.listenToStore]. /// /// Used to make sure that callers never reference the store without calling /// listen() first. In the example below, _itemStore would not be globally /// available: /// /// ```dart /// final _itemStore = new AppStore(actions); /// final itemStoreToken = new StoreToken(_itemStore); /// ``` class StoreToken { /// Creates a store token for the given store. StoreToken(this._value); final FluxStore _value; @override bool operator ==(dynamic other) { if (other.runtimeType != runtimeType) return false; final StoreToken typedOther = other; return identical(_value, typedOther._value); } @override int get hashCode => identityHashCode(_value); @override String toString() => '[${_value.runtimeType}(${_value.hashCode})]'; }
flutter_flux.dart
// Copyright 2015 Workiva Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /// The flutter_flux library implements a unidirectional data flow pattern /// comprised of [Action]s, [Store]s, and [StoreWatcher]s. /// /// - [Action]s initiate mutation of app data that resides in [Store]s. /// - Data mutations within [Store]s trigger re-rendering of a widget (defined /// in [StoreWatcher]s). /// - [StoreWatcher]s dispatch [Action]s in response to user interaction. export 'src/action.dart'; export 'src/store.dart'; export 'src/store_watcher.dart';
上記で加工したファイルを、以下のように配置しました。

※上記で利用しているaction.dart、store.dartのクラス名はオリジナルのクラス名と意図的に変更しております。
Action→FluxAction、Store→FluxStore
flutter/material.dartにも同じ名前のActionクラスが存在しており、ビルドでエラーになってしまうため。
main.dartの整理
プロジェクト作成直後に生成されているmain.dartを見てみましょう。
(以下、不要なコメントは削除済みです)
main.dart
import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key? key, required this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headline4, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), ); } }
このままだと見栄えが悪いので、classごとにファイルを分けます。
分けたあとの内容が以下になります。
main.dart
import 'package:flutter/material.dart'; import 'package:helloworld/MyApp.dart'; void main() { runApp(MyApp()); }
MyApp.dart
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:helloworld/MyHomePage.dart'; class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } }
MyHomePage.dart
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class MyHomePage extends StatefulWidget { MyHomePage({Key? key, required this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headline4, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), ); } }
動作確認
上記までコード分けしたら、動作確認してみます。
以下の画面が表示されることを確認してください。

これで開発準備は完了です。
Fluxパターンに置き換え
毎度おなじみの「+」ボタン押下で画面中央の値が変わるというサンプルコードの仕様をそのままFluxに置き換えてみます。
Actionの定義
まずは以下の内容で、Actionの定義を作ります。
import 'package:flux_test_app/flux/src/action.dart';
final FluxAction<int> incrementAction = FluxAction<int>();
本件では、インクリメントのアクションのみなので、上記の定義一つだけになります。int
にはAction時に渡すパラメータを設定します。
本件では、カウンターの既存値を渡してインクリメントしたいので、int
にしています。
Storeの定義
Storeの定義を作成します。
import 'IncrementAction.dart';
import 'flux/src/store.dart';
import 'flux/src/store_watcher.dart';
// IncrementStoreのオブジェクト取得時に利用する
final StoreToken incrementStoreToken = StoreToken(IncrementStore());
class IncrementStore extends FluxStore {
int _counter = 0;
IncrementStore() {
// コンストラクタで、Action実行時のトリガー処理を定義
triggerOnAction(incrementAction, onAction: (int value){
// Actionからの引数に+1したものを_counterに設定
_counter = value + 1;
});
}
int get counter => _counter;
}
MyHomePageのコード変更
_MyHomePageStateクラスの以下の箇所を変更します。
class _MyHomePageState extends State<MyHomePage> with StoreWatcherMixin<MyHomePage> { // with StoreWatcherMixin追加
// int _counter = 0; <--- Store側でカウンター管理するので、これを削除し
late IncrementStore incrementStore; // <--- 代わりにStoreを追加
initState()をオーバーライドし、listenToStoreでIncrementStoreを取得
@override
void initState() {
super.initState();
incrementStore = listenToStore(incrementStoreToken, onStoreChanged: null) as IncrementStore;
}
_incrementCounter()メソッドのsetStateを削除し、incrementAction()を実行する。
void _incrementCounter() {
incrementAction(payload: incrementStore.counter);
}
以上でMyHomePageの置き換え完了です。
置き換え後のコードは以下になります。
置き換え後のMyHomePage.dart
import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'IncrementAction.dart'; import 'IncrementStore.dart'; import 'flux/src/store_watcher.dart'; class MyHomePage extends StatefulWidget { MyHomePage({Key? key, required this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> with StoreWatcherMixin<MyHomePage> { late IncrementStore incrementStore; @override void initState() { super.initState(); incrementStore = listenToStore(incrementStoreToken, onStoreChanged: null) as IncrementStore; } void _incrementCounter() { incrementAction(payload: incrementStore.counter); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'You have pushed the button this many times:', ), Text( '${incrementStore.counter}', style: Theme.of(context).textTheme.headline4, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), // This trailing comma makes auto-formatting nicer for build methods. ); } }
動作確認
ここまで実装完了したら、アプリを起動してください。
変更前と同様に動作することができればOKです。
Actionの追加方法
さすがにActionが一つだけというと物足りないところではあるので、本件のパターンでActionを追加するときのコーディングについて記載します。
IncrementAction.dartに追記
Incrementが加算なので、減算Decrementの追加をしてみます。
IncrementAction.dartに以下の内容で追記します。
(※「IncrementAction.dart」なのに、decrementAction追加は違和感ありますが・・・)
import 'package:flux_test_app/flux/src/action.dart';
final FluxAction<int> incrementAction = FluxAction<int>();
final FluxAction<int> decrementAction = FluxAction<int>();
IncrementStore.dartにアクショントリガー追記
上記で追加したアクション実行時のトリガーを追加します。
import 'IncrementAction.dart';
import 'flux/src/store.dart';
import 'flux/src/store_watcher.dart';
final StoreToken incrementStoreToken = StoreToken(IncrementStore());
class IncrementStore extends FluxStore {
int _counter = 0;
IncrementStore() {
triggerOnAction(incrementAction, onAction: (int value){
_counter = value + 1;
});
// マイナス時のアクショントリガー追加
triggerOnAction(decrementAction, onAction: (int value){
_counter = value - 1;
if (_counter < 0) {
_counter = 0;
}
});
}
int get counter => _counter;
}
MyHomePage.dartの編集
_decrementCounter()を追加し、そこにアクションの実行を設定します。
void _incrementCounter() {
incrementAction(payload: incrementStore.counter);
}
// 以下の内容を追加
void _decrementCounter() {
decrementAction(payload: incrementStore.counter);
}
デクリメント用のボタンを追加します。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'${incrementStore.counter}',
style: Theme.of(context).textTheme.headline4,
),
FloatingActionButton( // <--- ここに追加
onPressed: _decrementCounter, // <--- デクリメントするメソッドを設定
tooltip: 'Decrement',
child: Icon(Icons.exposure_minus_1),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
ここまで追加したら、アプリを実行します。
「-1」ボタンを押下したら、カウンターがデクリメントされることが確認できるかと思います。
このように、Fluxパターンでは、Actionの追加も容易にできることが、ご理解いただけたかと思います。
まとめ
Googleがgithub上でFluxパターンを公開しており、せっかくなのでそのパターンを実装してみました。
確かにFluxな動きかな・・・と思っておりますが、なんせ3年前のコードになるので、最新のFlutterの機能であれば、もっと効率的なコードで実装できるのでは?と率直に思いました。
業務で利用できるかは何ともいえないところですが、データの流れが一方向になるのは魅力的かと思います。
そういう意味ではReduxという選択肢もあるので、引き続き研究していければと思います。
なお、開発者T的にはMVVMがシンプルでいいかな?と思っています。
以上、ありがとうございました。