システム開発部の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がシンプルでいいかな?と思っています。
以上、ありがとうございました。








