43
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

変更後のコード

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

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

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

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

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

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

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

ここで総額(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");
        });
  }
}

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

リンク

43
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?