Posted at

Build reactive mobile apps with Flutter (Google I/O '18) メモ

More than 1 year has passed since last update.

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を例に見ていきます。


元のコード


main.dart

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),
),
);
}
}


https://github.com/filiph/state_experiments/blob/master/hello_world_start/lib/main.dart


変更後のコード

Incrementerクラスを定義。引数にコールバック関数を受け取って onPressed() で実行するようにしている。


main.dart

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() を実行。


main.dart

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 :thumbsdown:

    • Updating on change :thumbsdown:



  • シンプルなアプリではこれでも十分な場合もあるが、大きなアプリでは管理が複雑になる。


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を再描画できる。





  • 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 :thumbsup:

    • Updating on change :thumbsup:

    • Mutating state :thumbsdown:



  • 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のみ再描画することができる。


model.dart

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();
}
}


https://github.com/filiph/state_experiments/blob/master/shared/lib/src/scoped/model.dart

CartPageは bodyScopedModelDescendant を渡す。


scoped_cart_page.dart

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());
}),
);
}
}


https://github.com/filiph/state_experiments/blob/master/shared/lib/src/scoped/scoped_cart_page.dart

MyAppでは ScopedModel Widgetで元々のWidget treeをWrapする。

Widget treeの一番上に状態を持つことになります。


complete.dart

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 を使う。


complete.dart

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の再描画をさせないようにできる。


complete.dart

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/complete.dart

(変更前のコード)

https://github.com/filiph/state_experiments/blob/master/shared/lib/src/scoped/start.dart


  • こうすることで中間のWidgetにコールバックを渡さなくても任意のWidget Treeにアクセスできる。

  • Pros/Cons


    • Access state :thumbsup:

    • Notify other widgets :thumbsup:

    • Minimal rebuild :thumbsdown:




3. Reactive with streams (BLoCStreamを使うパターン)

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ライブラリーの基本的なクラス。


cart_bloc.dart

class CartBloc {

final Cart _cart = Cart();

Sink<CartAddition> get cartAddition => _cartAdditionController.sink;
final StreamController<CartAddition> _cartAdditionController =
StreamController<CartAddition>();
}


https://github.com/filiph/state_experiments/blob/master/shared/lib/src/bloc/cart_bloc.dart

商品のonTap()に先程作ったSinkのメソッドを指定。


main.dart

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(),
);
}
}

https://github.com/filiph/state_experiments/blob/master/shared/lib/src/bloc/main.dart


cart_bloc.dart

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>();
}


https://github.com/filiph/state_experiments/blob/master/shared/lib/src/bloc/cart_bloc.dart

CartButtonWidgetをStreamBuilderでWrapする。

StreamBuilderstreamCartBlocitemCountを指定して購読(変更を監視)する。

CartButtonWidgetのitemCountにはsnapshot.dataを指定することでstream(CartBlocitemCount)の最新の値を表示することができる。


main.dart

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(),
);
}
}

https://github.com/filiph/state_experiments/blob/master/shared/lib/src/bloc/main.dart

StreamController.stream.listen()で入力のストリームに対しての処理を書くことができる。

ここでは入力のストリーム(商品追加)がきたらitemCountを追加している。


cart_bloc.dart

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);
}
});
}
}


https://github.com/filiph/state_experiments/blob/master/shared/lib/src/bloc/cart_bloc.dart

ここで総額(totalCost)と商品一覧(items)を追加してみます。


cart_bloc.dart

class CartBloc {

Sink<Product> addition;
Stream<int> itemCount;
Stream<int> totalCost;
Stream<List<CartItem>> items;
}

フォーマットした金額を表示したい場合はstreamintで渡すと"${snapshot.data ~/ 100} USD"のように変換する必要がありこれはまさにビジネスロジックです。


bloc_examples.dart

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");
});
}
}

https://github.com/filiph/state_experiments/blob/master/shared/example/bloc_examples.dart

Sink<local>を追加してtotalCostlocalに応じて変換するようにすることでロジックをBLoC内部に閉じることができる。


cart_bloc.dart

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 :thumbsup:

    • Updating on change :thumbsup:

    • Mutating state :thumbsup:



  • 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アプリだけ作るときでも適用したほうが良さそう。




リンク