Google I/O 2018のFlutter関連セッションの一つの Build reactive mobile apps with Flutter (Google I/O '18) のメモです。
詳しくは動画を見てください。
メモ
Flutterで色々な種類のアプリが作られている。
https://flutter.io/showcase/
- FlutterはReactive Model
- UI Componentは全てWidgetである。
- Widgetを使って複雑なUIを構築できる。
- Flutterは状態とUIの関係を管理し、setState() を呼ぶとWidgetが再描画される。
(引用) https://youtu.be/RS36gBEp8OI?t=1m18s
- FlutterのUIパターンはよく知られていて、Widget Catalog にドキュメント化されています。
- 状態の管理についてはあまり知られていないので、今日はこの話をします。
1. Flutter & state (setState()
をそのまま使うパターン)
Flutterを最初に環境構築したときについているIncrement Appを例に見ていきます。
元のコード
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Flutter Demo Home Page')),
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.display1),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_counter += 1;
});
},
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
変更後のコード
Incrementerクラスを定義。引数にコールバック関数を受け取って onPressed()
で実行するようにしている。
class Incrementer extends StatefulWidget {
final Function increment;
Incrementer(this.increment);
@override
IncrementerState createState() {
return new IncrementerState();
}
}
class IncrementerState extends State<Incrementer> {
@override
Widget build(BuildContext context) {
return new FloatingActionButton(
onPressed: widget.increment,
tooltip: 'Increment',
child: new Icon(Icons.add),
);
}
}
元のクラスのほうでは _increment()
で setState()
を実行。
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _increment() {
setState(() {
_counter += 1;
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text(
'You have pushed the button this many times:',
),
new Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: new Incrementer(_increment),
);
}
}
まとめ
- このようなコールバックを使うやり方だと、より多くのWidgetを持つアプリだと問題になることがある。
- あるWidgetが別のWidgetの状態を変更したいときにWidget Treeの親のWidgetが状態を知る必要がある。
- WIdgetの再利用が難しくなる。
- 関心事が分離できていない。
- 再描画の必要のないWidgetまで再描画されてしまう。
(引用) https://youtu.be/RS36gBEp8OI?t=8m18s
- このコールバックを利用するパターンは以下のような評価になる。
- Accessing state
- Updating on change
- シンプルなアプリではこれでも十分な場合もあるが、大きなアプリでは管理が複雑になる。
2. State & the widget tree(InheritedWidget
を使うパターン)
- 変更をWidget Treeの上下に渡すとその間にあるWidget全てが状態を知る必要がある。
- この問題を解決するのが、 Inherited Widget です。
- Inherited widget
- Propagate data down the tree
- データをWidget Treeの下のWidgetに通知できる。
- Update widgets on rebuild
- 状態が変化したときにWidgetを再描画できる。
- Propagate data down the tree
- We can use BuildContext in any of the build method to get an access to the instance of that state and use it directly within our widgets themselves.
(引用) https://youtu.be/RS36gBEp8OI?t=10m7s
class MyInheritedWidget extends InheritedWidget {
final Data state;
...
}
Widget build(BuildContext context) =>
return MyInheritedWidget(
state: mState,
child: WidgetTree(...)
);
Widget build(BuildContext context) {
final state = MyInheritedWidget.of(context).state;
...
}
まとめ
- このようにInheritedWidgetを使うパターンの評価は以下のようになります。
- Accessing state
- Updating on change
- Mutating state
- Scoped Modelを使うとMutating stateを改善できる。
- Scoped Model
- External package
- Build on InheritedWidget
- Access, Update & Mutate
Increment AppだとシンプルすぎるのでShopping Cartを例に説明。
おおまかなWidget Treeはこのような形。
(引用) https://youtu.be/RS36gBEp8OI?t=10m7s
まずCartModelを定義。
add()
で notifyListeners()
を呼ぶことでListenしているWidgetにのみ変更を通知することができるので、Widget Tree全てを再描画せずに必要なWidgetのみ再描画することができる。
class CartModel extends Model {
final _cart = Cart();
List<CartItem> get items => _cart.items;
int get itemCount => _cart.itemCount;
void add(Product product) {
_cart.add(product);
notifyListeners();
}
}
CartPageは body
に ScopedModelDescendant
を渡す。
class CartPage extends StatelessWidget {
static const routeName = '/cart';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Your Cart'),
),
body: ScopedModelDescendant<CartModel>(builder: (context, _, model) {
if (model == null || model.items.isEmpty) {
return Center(
child: Text('Empty', style: Theme.of(context).textTheme.display1),
);
}
return ListView(
children: model.items.map((item) => ItemTile(item: item)).toList());
}),
);
}
}
MyAppでは ScopedModel
Widgetで元々のWidget treeをWrapする。
Widget treeの一番上に状態を持つことになります。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// SCOPED MODEL: Inserts a ScopedModel widget into the widget tree
return ScopedModel<CartModel>(
model: CartModel(),
child: MaterialApp(
title: 'Scoped Model',
theme: appTheme,
home: CatalogHomePage(),
routes: <String, WidgetBuilder>{
CartPage.routeName: (context) => CartPage(),
},
debugShowCheckedModeBanner: false,
),
);
}
}
商品数を表示するCatalogHomePageでは ScopedModelDescendant
を使う。
class CatalogHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scoped Model'),
actions: <Widget>[
// SCOPED MODEL: Wraps the cart button in a ScopdeModelDescendent to access
// the nr of items in the cart
ScopedModelDescendant<CartModel>(
builder: (context, child, model) => CartButton(
itemCount: model.itemCount,
onPressed: () {
Navigator.of(context).pushNamed(CartPage.routeName);
},
),
)
],
),
body: ProductGrid(),
);
}
}
各商品のWidgetにも ScopedModelDescendant
を使う。
各商品はタップしても状態を変える必要がないので rebuildOnChange = false
を指定することでWidgetの再描画をさせないようにできる。
class ProductGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
children: catalog.products.map((product) {
// SCOPED MODEL: Wraps items in the grid in a ScopedModelDecendent to access
// the add() function in the cart model
return ScopedModelDescendant<CartModel>(
rebuildOnChange: false,
builder: (context, child, model) => ProductSquare(
product: product,
onTap: () => model.add(product),
),
);
}).toList(),
);
}
}
(変更前のコード)
https://github.com/filiph/state_experiments/blob/master/shared/lib/src/scoped/start.dart
- こうすることで中間のWidgetにコールバックを渡さなくても任意のWidget Treeにアクセスできる。
- Pros/Cons
- Access state
- Notify other widgets
- Minimal rebuild
3. Reactive with streams (BLoC
とStream
を使うパターン)
FlutterにおけるStreamはReactiveXのObservablesと似ている。
UIに対する全ての変更は非同期イベントのストリームである。
(引用) https://youtu.be/RS36gBEp8OI?t=19m5s
DartはStreamという非同期処理用の仕組みがある。
それをReactiveXで拡張したのがRxDart。
(引用) https://youtu.be/RS36gBEp8OI?t=20m12s
FlutterのWidgetでStreamを処理するのがStreamBuilder。
(引用) https://youtu.be/RS36gBEp8OI?t=20m28s
入力として受け取ったStreamを都度ビルドするのが builder
メソッド。
StreamBuilder<T>(
stream: input,
builder: (context, snapshot) {
...
},
)
ユーザーの入力を受け付けるWidgetと状態変化に応じて更新するWidgetがそれぞれ複数ある場合、単純に2つのWidgetをつなげるだけではうまくいきません。
図のようにビジネスロジックを介してイベント処理をする必要があります。
ここで重要なのは別々のStreamを公開していることです。
Streamを購読しているWidgetはモデルに変更があるときのみ再描画されます。
(引用) https://youtu.be/RS36gBEp8OI?t=22m04s
一般的なbusiness logicの例
Sink
は入力Stream、Stream
は出力Stream。
class Foo {
Sink<Event> input1;
Sink<Event> input2;
Stream<Data> output1;
Stream<Data> output2;
}
ショッピングカートのbusiness logicの例
Google内部ではこれをbusiness logic component(略してBLoC)と呼んでいる。
class Cart {
Sink<Product> addition;
Stream<int> itemCount;
}
まず入力StreamとしてSink<CartAddition>
を用意。
StreamController
はDartのStreamライブラリーの基本的なクラス。
class CartBloc {
final Cart _cart = Cart();
Sink<CartAddition> get cartAddition => _cartAdditionController.sink;
final StreamController<CartAddition> _cartAdditionController =
StreamController<CartAddition>();
}
商品のonTap()
に先程作ったSink
のメソッドを指定。
class ProductGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cartBloc = CartProvider.of(context);
return GridView.count(
crossAxisCount: 2,
children: catalog.products.map((product) {
return ProductSquare(
product: product,
onTap: () {
cartBloc.cartAddition.add(CartAddition(product));
},
);
}).toList(),
);
}
}
class CartBloc {
final Cart _cart = Cart();
Sink<CartAddition> get cartAddition => _cartAdditionController.sink;
final StreamController<CartAddition> _cartAdditionController =
StreamController<CartAddition>();
Stream<int> get itemCount => _itemCount.stream;
final BehaviorSubject<int> _itemCount =
BehaviorSubject<int>();
}
CartButton
WidgetをStreamBuilder
でWrapする。
StreamBuilder
のstream
にCartBloc
のitemCount
を指定して購読(変更を監視)する。
CartButton
WidgetのitemCount
にはsnapshot.data
を指定することでstream
(CartBloc
のitemCount
)の最新の値を表示することができる。
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cartBloc = CartProvider.of(context);
return Scaffold(
appBar: AppBar(
title: Text("Bloc"),
actions: <Widget>[
StreamBuilder<int>(
stream: cartBloc.itemCount,
initialData: 0,
builder: (context, snapshot) => CartButton(
itemCount: snapshot.data,
onPressed: () {
Navigator.of(context).pushNamed(BlocCartPage.routeName);
},
),
)
],
),
body: ProductGrid(),
);
}
}
StreamController.stream.listen()
で入力のストリームに対しての処理を書くことができる。
ここでは入力のストリーム(商品追加)がきたらitemCount
を追加している。
class CartBloc {
final Cart _cart = Cart();
Sink<CartAddition> get cartAddition => _cartAdditionController.sink;
final StreamController<CartAddition> _cartAdditionController =
StreamController<CartAddition>();
Stream<int> get itemCount => _itemCount.stream;
final BehaviorSubject<int> _itemCount =
BehaviorSubject<int>();
CartBloc() {
_cartAdditionController.stream.listen((addition) {
int currentCount = _cart.itemCount;
_cart.add(addition.product, addition.count);
_items.add(_cart.items);
int updatedCount = _cart.itemCount;
if (updatedCount != currentCount) {
_itemCount.add(updatedCount);
}
});
}
}
ここで総額(totalCost
)と商品一覧(items
)を追加してみます。
class CartBloc {
Sink<Product> addition;
Stream<int> itemCount;
Stream<int> totalCost;
Stream<List<CartItem>> items;
}
フォーマットした金額を表示したい場合はstream
をint
で渡すと"${snapshot.data ~/ 100} USD"
のように変換する必要がありこれはまさにビジネスロジックです。
class AwkwardWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<int>(
stream: cartBloc.total,
initialData: 0,
builder: (context, snapshot) {
final formated = "${snapshot.data ~/ 100} USD";
return Text("Total: $formated");
});
}
}
Sink<local>
を追加してtotalCost
をlocal
に応じて変換するようにすることでロジックをBLoC内部に閉じることができる。
class CartBloc {
Sink<Product> addition;
Sink<Locale> locale;
Stream<int> itemCount;
Stream<String> totalCost; // e.g. "$1.00 USD"
Stream<List<CartItem>> items;
}
まとめ
- このパターンのPros/Cons
- Accessing state
- Updating on change
- Mutating state
- Options
- setState()
- Scoped Model
- Redux
- ...
- Widgets + Streams = Reactive
所感
- 簡単なアプリであれば
setState()
だけで事足りそう。 -
InheritedWidget
は画面がもう多いけど状態が少ないアプリであれば良さそう(かも)。- 状態が増えてくるとつらそう(複数のWidgetが更新するような場合)
- 更新(rebuild)が必要なWidget以外を更新させないようにするにはオプション指定が必要になるので管理が大変になってきそう。
- ある程度の複雑なアプリはこのセッションの発表していたFlipさんがおすすめしていたflutter_reduxを使ったほうが良さそう。
- 責務が分離されて単体テストが書きやすくなる。
- Action, Reducer, Storeなどを作る必要があるのでコード量が増える。
- 結局は目的次第でアーキテクチャを選ぶと良さそう。
- BLoCに関してはFlutter / AngularDart – Code sharing, better together (DartConf 2018) - YouTubeも参考になる。
- FlutterにおけるBLoCはFlutterとDartのWebアプリケーション(AnglarDart)でビジネスロジックを共通化するために出てきた概念らしい。
責務分離やテストなどを考えるとFlutterアプリだけ作るときでも適用したほうが良さそう。
- FlutterにおけるBLoCはFlutterとDartのWebアプリケーション(AnglarDart)でビジネスロジックを共通化するために出てきた概念らしい。