More Related Content Similar to Flutter のリアクティブ戦略 set state 〜 redux まで (20) Flutter のリアクティブ戦略 set state 〜 redux まで3. [Session] Build reactive mobile apps with Flutter
2018/05/10 (Thu) 10:30AM - 11:30AM Stage 3
https://events.google.com/io/schedule/?section=may-8&sid=dab2bf45-6e44-4605-a997-9d446f95ef38
Build reactive mobile apps with Flutter (Google I/O '18)
https://www.youtube.com/watch?v=RS36gBEp8OI
3
この LT は、Google I/O 2018 セッション
Build reactive mobile apps with Flutter の概要です。
セッション中のスライドを利用していますが、
構成都合のためにビデオ中での説明からの意訳や
独自の説明に置き換えている点に御留意ください。
6. state_experiments リポジトリの使い方
利用方法
下記コマンドでリポジトリをクローンして、
クローン先の shared ディレクトリ(プロジェクト)を
IntelliJ IDE で開いてください。
設計パターンごとのアプリをビルドしたい場合は、
lib/main.dart の main 関数の設計種別を表す flavor の
enum 値の書き換えが必要です。
例) final flavor = Archtecture.redux; ⇒ final flavore = Archtecture.bloc;
6
$ cd サンプルディレクトリ
$ git clone https://github.com/filiph/state_experiments.git
7. 【補足事項】
● Android アプリをビルドする場合は、 gradle-wrapper.jar を追加してください。
プロジェクトの android/gradle/wrapper には、gradle-wrapper.jar が含まれていません。
このため適当なプロジェクトを作成して、そこから gradle-wrapper.jar をコピーしてください。
● サンプルプロジェクトは、 master channel のSDKでビルドしてください。
自分の環境の確認や master への切替は、下記を御参照ください。
7
# 現在どの channel が選択されているかチェック
$ flutter channel
Flutter channels:
beta
dev
* master
# channel を master に切り替え
$ flutter channel master
# channel の確認&SDKのアップデート
$ flutter doctor
9. セッションの構成
1. Flutter & state
Flutter と State の基礎
2. State & the widget tree
ウィジェットの State アクセス方法
3. Reactive with streams
ストリームを使った State のエレガントな管理法
9
以上の3ステップから構成されています
12. 12
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _increment() {
setState(() {
_counter += 1;
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
body: …,
children: <Widget>[
…,
new Text(
'$_counter', …,
),
],
),
floatingActionButton: new Incrementer(_increment),
);
}
}
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,
…,
);
}
}
以降のスライドのコード中核は、
このような構成になっています。
詳しくは、state_experiments サンプルの
hello_world ディレクトリ(プロジェクト)の
lib/main.dart を参照ください。
17. 17
サンプルでは、
State のカウンタ値を +1 して
setState()で再描画を行わせる関数(*1)
を
コンストラクタに渡しています。
(*1)
_MyHomePageState の _increment
親ウィジェットの関数をコールするための
古典的なコールバックメソッド
呼び出した先でなく、
setState() の所有ウィジェット(*2)
が
再描画されることに注意してください
(*2)
MyHomePage ウィジェット
この手法では、State を伝播させる経路の
全ウィジェットで State を扱わせてしまいます。
MyHomePageから
FAB も Text も再描画
されてしまいます。
20. 2. State & the widget tree
ウィジェットの State アクセス方法
20
22. 22
Flutter の InheritedWidget クラスを継承したウィジェットを作れば、
State/状態 の保持とウィジェットツリー下流への伝播および、
State/状態 の参照と更新も実現できます。
InheritedWidget
https://docs.flutter.io/flutter/widgets/InheritedWidget-class.html
32. 32
State/カート状態 をハンドルするウィジェットたち
1. 四角い Product グリッド をタッチすると、
カートにタッチした 商品 が入ります。
2. 画面右上の Cart Button には、
カートに入った 商品 数が表示されます。
3. Cart Button をタッチすると Cart Page に
カートに入れた 商品リストを表示します。
37. state_experiments サンプルの lib/src/scoped より抜粋
37
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ScopedModel<CartModel>(
model: CartModel(),
child: MaterialApp(
…,
home: CatalogHomePage(),
routes: <String, WidgetBuilder>{
CartPage.routeName: (context) => CartPage(),
},
…,
),
);
}
// 継承元の Model は、
// scoped_model パッケージの抽象クラスです。
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(); // 全参照ウィジェットを再描画
}
}
カートへの商品追加時に、
カート状態を参照する全ウィジェット
(ScopedModelDescendant のウィジェット)を再
描画させます。
38. 38
class CartPage extends StatelessWidget {
static const routeName = '/cart';
@override
Widget build(BuildContext context) {
return Scaffold(
…,
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());
}),
);
}
}
class CatalogHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
…,
actions: <Widget>[
ScopedModelDescendant<CartModel>(
builder: (context, child, model) => CartButton(
itemCount: model.itemCount,
onPressed: () {
Navigator.of(context).pushNamed(
CartPage.routeName);
},
),
)
],
),
body: ProductGrid(),
);
}
}
39. 39
class ProductGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
children: catalog.products.map((product) {
return ScopedModelDescendant<CartModel>(
rebuildOnChange: false,
builder: (context, child, model) =>
ProductSquare(
product: product,
onTap: () => model.add(product),
),
);
}).toList(),
);
}
}
● ProductSquare (商品グリッド)は、
初期表示から変わらないので、
再描画する必要はありませんが …
タッチされたときに
カートに商品を追加するため、
CartModel を参照しているので、
ScopedModel の再描画対象になります。
● このため
rebuildOnChange フラグを false にして、
CartModel#add での notifyListeners() で、
再描画されないようにする必要があります。
41. 状態モデル、状態提供ウィジェット、状態参照ウィジェットの定義
● 状態モデル
取り扱いたい State/状態 を提供する
scoped_model パッケージの Model を継承したクラス
● 状態提供ウィジェット
build() メソッドの返値として、
あるいは返値の Widget 型のプロパティに対して、
ScopedModel<状態モデル> を割り当てる ウィジェット
● 状態参照ウィジェット
build() メソッドの返値として、
あるいは返値の Widget 型のプロパティに対して、
ScopedModelDescendant<状態モデル> を割り当てる ウィジェット
41
CartModel クラスを参照
MyApp クラスを参照
CatalogHomePage, CartPage, ProductGrid クラスを参照
42. 状態モデルの参照 / 状態変更
● 状態モデルの参照 / 状態変更
状態参照ウィジェットの builder() メソッド引数の model に
状態モデル への参照が割り当てられるので、model 引数を介して、
必要なプロパティの参照やメソッドの実行が行えます。
サンプルではウィジェット構築時やタッチイベントで使っています。
CatalogHomePage, CartPage, ProductGrid クラスを参照
42
43. 再描画 / 再描画抑止
● 全ての状態参照ウィジェットの再描画
状態モデルの再描画を行いたいイベントのハンドル先で、
notifyListeners() メソッドを実行させます。
ProductSquare:onTap, CardModel#add(Product) 参照
● ウィジェット再描画抑止
ウィジェットの再描画の必要のない状態参照ウィジェットの
ScopedModelDescendant のプロパティ rebuildOnChange に false を
設定します。
ProductGrid#build() 参照
43
74. 1. Sink で Widget の入力イベントを取得し
2. Stream に最新データの出力を行い
3. Widget を最新化(再描画)するような、
4. (ある責務をまとめた)任意の実装単位 ⇒
ビジネスロジックのコンポーネント
Business Logic Component を
BLoC (Business Logic Component) と呼んでいるそうです。
Build reactive mobile apps with Flutter (Google I/O '18)
https://youtu.be/RS36gBEp8OI?t=1387 (22:00頃)
74
75. Flutter での BLoC パターンやその思想については、
Dart Meetup Tokyo 管理者の laco 氏のブログ記事が
参考になります。
FlutterのBLoCパターンをAngularで理解する
https://lacolaco.hatenablog.com/entry/2018/05/22/194805
Dart Meetup Tokyo
https://dartisans-jp.connpass.com/
75
77. state_experiments サンプルの lib/src/bloc より抜粋
77
void main() { runApp(MyApp()); }
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CartProvider(
child: MaterialApp(
…,
home: MyHomePage(),
routes: <String, WidgetBuilder>{
BlocCartPage.routeName: (context) =>
BlocCartPage()
},
),
);
}
}
class CartProvider extends InheritedWidget {
final CartBloc cartBloc;
CartProvider({
Key key,
CartBloc cartBloc,
Widget child,
}) : cartBloc = cartBloc ?? CartBloc(),
super(key: key, child: child);
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
static CartBloc of(BuildContext context) =>
(context.inheritFromWidgetOfExactType(CartProvider) as
CartProvider).cartBloc;
}
カート状態 CartBloc (business logic含) は
InheritedWidget を継承させた CartProvider
の of() メソッドを介して、
ウィジェットツリー下流から
取得可能になっています。
78. 78
import 'package:rxdart/subjects.dart';
class CartAddition {
final Product product;
final int count;
CartAddition(this.product, [this.count = 1]);
}
class CartBloc {
final Cart _cart = Cart();
final BehaviorSubject<List<CartItem>> _items =
BehaviorSubject<List<CartItem>>(seedValue: []);
final BehaviorSubject<int> _itemCount =
BehaviorSubject<int>(seedValue: 0);
final StreamController<CartAddition>
_cartAdditionController =
StreamController<CartAddition>();
…右に続く
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);
}
});
}
Sink<CartAddition> get cartAddition =>
_cartAdditionController.sink;
Stream<int> get itemCount => _itemCount.stream;
Stream<List<CartItem>> get items => _items.stream;
void dispose() {
…
}
}
79. 79
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cartBloc = CartProvider.of(context);
return Scaffold(
appBar: AppBar(
title: 'Bloc',
actions: <Widget>[
StreamBuilder<int>(
stream: cartBloc.itemCount,
initialData: 0,
builder: (context, snapshot) => CartButton(
itemCount: snapshot.data,
onPressed: () { … },
),
)
],
),
body: ProductGrid(),
);
}
}
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(),
);
}
}
80. class BlocCartPage extends StatelessWidget {
BlocCartPage();
static const routeName = "/cart";
@override
Widget
80
class BlocCartPage extends StatelessWidget {
BlocCartPage();
static const routeName = "/cart";
@override
Widget build(BuildContext context) {
final cart = CartProvider.of(context);
return Scaffold(
appBar: AppBar(
title: Text("Your Cart"),
),
body: StreamBuilder<List<CartItem>>(
stream: cart.items,
builder: (context, snapshot) {
if (snapshot.data == null || snapshot.data.isEmpty) {
return Center(
child: Text('Empty',
style:Theme.of(context)
.textTheme.display1));
}
…右に続く
return ListView(
children: snapshot.data
.map((item) => ItemTile(item: item))
.toList());
}));
}
}
Sink と Stream オブジェクトに関連する
クラスとフィールドとメソッドのみ、
型ごとに色を分けています。
81. ショッピングカート・アプリ例の
入力イベント ~ UI 最新化までの処理フローについては、
スライド 62 ~ 65 を参考に、lib/src/bloc より抜粋した
コードを御参照ください。
81
Productグリッドのタッチ
⇒ CartへのProduct追加
⇒ CartButtonアイテム総数UI の再描画のフローなら、
・ProuctGrid 内の onTap ハンドラ、
・CartBloc の StreamController _cartAdditionCotroller や
_cartAdittionController.stream.listen() に_cartAdditionController.sink、
・MyHomePage での StreamBuilder<int>() 辺りが参考になると思います。
82. ビジネスロジック コンポーネントでの
ポイント
● 入力〜出力のフローの始端と終端のみ公開し、依存の分離を高めます。
特定の入力イベント受付となる Sink<Event>オブジェクト と、
特定の出力データ窓口となる Stream<Data>オブジェクト のみ公開します。
入力ウィジェットからのイベントの通知やハンドル方法や、
出力ウィジェットへの最新データによる UI 最新化通知は、隠蔽します。
● bloc では、特定の実装方法はありません。
サンプルでは、StreamController<Event>とStreamBuilder<Data>を使って、
入力イベントのハンドルと出力データによるUI 最新化を行っていますが、
bloc では、イベント入力とデータ出力のインターフェースが、
Sink<Event> と Stream<Data> であればよく、
特定の実装方法はないそうです。
82
83. StreamController を使った
入力イベントの通知とハンドルのポイント
● StreamController<Event>
Event 入力をハンドルして必要な処理を行わせるコントローラの定義
stream:Stream<Event> と sink:Sink<Event> プロパティを所有しています。
● StreamController#stream.listen((Event){ …対応処理… })
Event対応ハンドラの登録
ハンドラでは、最新のEvent情報に依存する各種 Data の更新を行う。
● StreamController#sink.add(最新Event)
Event最新情報の入力イベントの通知
最新Event を引数に与えて、Event対応ハンドラを実行させるメソッド。
83
84. StreamBuilder を使った
最新データによる UI 最新化通知のポイント
● StreamBuilder<Data>
最新Data をUI に反映(Data と UI を同期)させる StreamBuilder の定義
StreamBuilder は、stream:Stream<Data> プロパティを所有しています。
● StreamBuilder#builder((BuildContext, AsyncSnapshot<Data>){ …UI構築… })
UI構築ハンドラの登録
引数の最新Data スナップショットから、最新Dataを反映した UI の構築を行う。
● StreamBuilde#stream
Dataの変更監視プロパティ
プロパティ stream の監視先が最新Dataに更新されると、
最新Dataを引数に与えて、UI構築ハンドラが実行されます。
84
106. ● StatefulWidget と setState() は、
浅いツリーかつ、アプリがシンプルであれば
問題ありません。
● ScopedModelは、
モデルが比較的簡単で、
任意の深さのツリー上での状態更新に適しています。
● reduxパターンが好きな人には、
コミュニティによって作られた優れた redux の実装…
Dart言語用の redux パッケージと
Flutter用の Flutter Redux があります。
106
107. 【補足】 Flutter Redux について
セッションでの説明がないため詳細は不明ですが、
state_experiments サンプルには、
Flutter Redux を使ったサンプルも含まれています。
よろしければ、
state_experiments サンプル shared ディレクトリ(プロジェクト)の
lib/src/redux のソースも御参照ください。
107
114. A Special thanks to Mr Fillip!
[Meetup] Flutter developers
2018/05/10 (Thu) 12:10PM - 12:40PM Community lounge
https://events.google.com/io/schedule/?section=may-9&sid=ba156a8e-3d16-4b29-9788-878f1f12ead9
114