FlutterにおけるState management

以下二つの記事の続きです。

今回はFlutterにおけるState managementについて書いていきます。

大体はここに書いてある内容を自分の勉強も兼ねてまとめていきます。


ソースコードはこちら。


2つの State

- Ephemeral (束の間の、はかない) state

例: 選択されたタブ、現在開いているページ、など

この Ephemeral State は複雑に変化しないし、他の箇所で使われることもないし、ユーザーがアプリをリスタートしたら失われても良いです。したがって、凝った State management 技術を使う必要もないです。

以下の例のように、Flutterでは StatefulWidget を使って、setState() してしまえば良いです。

class MyHomepage extends StatefulWidget {
 @override
 _MyHomepageState createState() => _MyHomepageState();
}

class _MyHomepageState extends State<MyHomepage> {
 int _index = 0;

 @override
 Widget build(BuildContext context) {
   return BottomNavigationBar(
     currentIndex: _index,
     onTap: (newIndex) {
       setState(() {
         _index = newIndex;
       });
     },
     // ... items ...
   );
 }
}


- App state

例: ユーザー情報、ログイン情報、通知、ショッピングカート、既読

こういった App state は複雑に変化し得るし、アプリ内のあらゆる場所で共有する必要があるし、ユーザーのセッション -> セッションで引き継ぐ必要があります。App state の管理には色々な方法があり、有名どころだとReduxやBLoCなどがあります。

以下のページに、有名どころの選択肢がまとめられています。


ちなみに公式ページ曰く、Flutter は declarative (宣言的) だそうで、以下のように考えることができるそうです。

ユーザーインターフェース = 関数f(状態) 

Flutterは状態を「更新・修正」するのではなく、「スクラッチから作り直して良い」そうで、それが出来る程度には十分速いそうです。


結局 App state の管理にどの方法を使うか

上記のFlutterの公式ページにも書いてありましたが、Provider というパッケージを用いた手法が推奨されているようです。

ほんの数年前までは BLoC が推奨されていたようですが、学習コストが高く、Providerが台頭してきたようです。

特に拘りはないので、推奨される Provider を使っていこうと思います。以下のページに、その Provider を使った State Management のサンプルが書いてあったので、これを参考にしてまとめていきます。



実践

上の記事中にあるアプリを作っていきます。カートの中身の状態は「ADD」によって変化していくので、State managementの題材として良さそうです。

画像2

上のように、全部で5つのWidgetを用意することになります。

まずCLIで土台のアプリを作成します。

$ flutter create provider_sample_tkugimot
$ cd provider_sample_tkugimot
$ git init
$ git add .
$ git commit -m 'Initial Commit'
$ git remote add origin git@github.com:tkugimot/provider_shopper_tkugimot.git
$ git push origin master

上記のディレクトリをAndroid studioで開いて上部バーの「>Run」をクリックして走らせると、カウンターのアプリがchromeが起動します。

pubspec.yamlにproviderを追記します。

# pubspec.yaml
dependencies:
 flutter:
   sdk: flutter
 provider: ^4.1.0

バージョンは元記事の3.0.0ではなく、4.1.0とします。以下の記事が目にとまって、どうやら4から導入された機能を使うとよりシンプルに書けそうだったためです。

https://ja.unflf.com/tech/flutter/provider/



まずはCatalogとCartのmodelを作ります。

$ cd lib
$ mkdir models

二つのファイルを作成します。

画像2

「CatalogModel.dart」と「CartModel.dart」を作ります。

#CataLogModel.dart
import 'package:flutter/material.dart';

/// A proxy of the catalog of items the user can buy.
///
/// In a real app, this might be backed by a backend and cached on device.
/// In this sample app, the catalog is procedurally generated and infinite.
///
/// For simplicity, the catalog is expected to be immutable (no products are
/// expected to be added, removed or changed during the execution of the app).
class CatalogModel {
 static List<String> itemNames = [
   'Code Smell',
   'Control Flow',
   'Interpreter',
   'Recursion',
   'Sprint',
   'Heisenbug',
   'Spaghetti',
   'Hydra Code',
   'Off-By-One',
   'Scope',
   'Callback',
   'Closure',
   'Automata',
   'Bit Shift',
   'Currying',
 ];

 /// Get item by [id].
 ///
 /// In this sample, the catalog is infinite, looping over [itemNames].
 Item getById(int id) => Item(id, itemNames[id % itemNames.length]);

 /// Get item by its position in the catalog.
 Item getByPosition(int position) {
   // In this simplified case, an item's position in the catalog
   // is also its id.
   return getById(position);
 }
}

@immutable
class Item {
 final int id;
 final String name;
 final Color color;
 final int price = 42;

 Item(this.id, this.name)
 // To make the sample app look nicer, each item is given one of the
 // Material Design primary colors.
     : color = Colors.primaries[id % Colors.primaries.length];

 @override
 int get hashCode => id;

 @override
 bool operator ==(Object other) => other is Item && other.id == id;
}


#CartModel.dart
import 'package:flutter/foundation.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';

class CartModel extends ChangeNotifier {
 /// The private field backing [catalog].
 CatalogModel _catalog;

 /// Internal, private state of the cart. Stores the ids of each item.
 final List<int> _itemIds = [];

 /// The current catalog. Used to construct items from numeric ids.
 CatalogModel get catalog => _catalog;

 set catalog(CatalogModel newCatalog) {
   assert(newCatalog != null);
   assert(_itemIds.every((id) => newCatalog.getById(id) != null),
   'The catalog $newCatalog does not have one of $_itemIds in it.');
   _catalog = newCatalog;
   // Notify listeners, in case the new catalog provides information
   // different from the previous one. For example, availability of an item
   // might have changed.
   notifyListeners();
 }

 /// List of items in the cart.
 List<Item> get items => _itemIds.map((id) => _catalog.getById(id)).toList();

 /// The current total price of all items.
 int get totalPrice =>
     items.fold(0, (total, current) => total + current.price);

 /// Adds [item] to cart. This is the only way to modify the cart from outside.
 void add(Item item) {
   _itemIds.add(item.id);
   // This line tells [Model] that it should rebuild the widgets that
   // depend on it.
   notifyListeners();
 }
}

CartModelの状態が変化するので、ChangeNotifierを継承しています。

notifyListeners(); が add()の中にあり、これがwidgetにrebuildせよと伝えるらしいです。この通知を受け取るのが後述する context.watch (もしくはcontext.read) です。

一応、各modelのテストも簡単に書いてみます。

$ cd ../test
$ mkdir models
$ cd models
$ touch CartModeTest.dart
$ touch CatalogModelTest.dart
# CartModelTest.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:provider_shopper_tkugimot/models/CartModel.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';

void main() {
 test('adding item increases total cost', () {
   final cart = CartModel();
   final startingPrice = cart.totalPrice;
   cart.add(Item(1, 'Summer'));
   cart.addListener(() {
     expect(cart.totalPrice, greaterThan(startingPrice));
   });
 });
}
# CatalgModelTest.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';

void main() {
 test('get first item from CatalogModel', () {
   final catalog = CatalogModel();
   final actual = catalog.getById(1);
   final expected = new Item(1, 'Control Flow');
   expect(actual, expected);
 });
}


notifyListeners() で状態の変化を通知するためには、ChangeNotifierのインスタンスを受け取る必要があります。それを提供するのが、今回の場合 main.dart の ChangeNotifierProxyProvider になります。必要以上に「上」に持っていかない方がスコープを狭く保てて良いですが、今回に関しては、CartModel.dartの上はmain.dartしかないです。

# main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:provider_shopper_tkugimot/models/CartModel.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';
import 'package:provider_shopper_tkugimot/screens/CatalogScreen.dart';

void main() {
 runApp(MyApp());
}

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   // Using MultiProvider is convenient when providing multiple objects.
   return MultiProvider(
     providers: [
       // In this sample app, CatalogModel never changes, so a simple Provider
       // is sufficient.
       Provider(create: (context) => CatalogModel()),
       // CartModel is implemented as a ChangeNotifier, which calls for the use
       // of ChangeNotifierProvider. Moreover, CartModel depends
       // on CatalogModel, so a ProxyProvider is needed.
       ChangeNotifierProxyProvider<CatalogModel, CartModel>(
         create: (context) => CartModel(),
         update: (context, catalog, cart) {
           cart.catalog = catalog;
           return cart;
         },
       ),
     ],
     child: MaterialApp(
       title: 'Provider Demo',
       initialRoute: '/catalog',
       routes: {
         '/catalog': (context) => MyCatalog(),
       },
     ),
   );
 }
}​

続いて、CatalogScreen で Catalogの画面(Widget)を作っていきます。

$ cd lib/
$ mkdir screens
$ touch CatalogScreen.dart
# CatalogScreen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:provider_shopper_tkugimot/models/CartModel.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';

class MyCatalog extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: CustomScrollView(
       slivers: [
         _MyAppBar(),
         SliverToBoxAdapter(child: SizedBox(height: 12)),
         SliverList(
           delegate: SliverChildBuilderDelegate(
                   (context, index) => _MyListItem(index)),
         ),
       ],
     ),
   );
 }
}

class _AddButton extends StatelessWidget {
 final Item item;

 const _AddButton({Key key, @required this.item}) : super(key: key);

 @override
 Widget build(BuildContext context) {
   var cart = Provider.of<CartModel>(context);

   return FlatButton(
     onPressed: cart.items.contains(item) ? null : () => cart.add(item),
     splashColor: Theme.of(context).primaryColor,
     child: cart.items.contains(item)
         ? Icon(Icons.check, semanticLabel: 'ADDED')
         : Text('ADD'),
   );
 }
}

class _MyAppBar extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return SliverAppBar(
     title: Text('Catalog', style: Theme.of(context).textTheme.headline1),
     floating: true,
     actions: [
       IconButton(
         icon: Icon(Icons.shopping_cart),
         onPressed: () => Navigator.pushNamed(context, '/cart'),
       ),
     ],
   );
 }
}

class _MyListItem extends StatelessWidget {
 final int index;

 _MyListItem(this.index, {Key key}) : super(key: key);

 @override
 Widget build(BuildContext context) {
   var catalog = Provider.of<CatalogModel>(context);
   var item = catalog.getByPosition(index);
   var textTheme = Theme.of(context).textTheme.headline6;

   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
     child: LimitedBox(
       maxHeight: 48,
       child: Row(
         children: [
           AspectRatio(
             aspectRatio: 1,
             child: Container(
               color: item.color,
             ),
           ),
           SizedBox(width: 24),
           Expanded(
             child: Text(item.name, style: textTheme),
           ),
           SizedBox(width: 24),
           _AddButton(item: item),
         ],
       ),
     ),
   );
 }
}
​

アプリを再起動すると、こんな感じになります。

画像3


続いて、Cartの画面を作っていきます。

$ cd lib/screens
$ touch CartScreen.dart
# CartScreen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:provider_shopper_tkugimot/models/CartModel.dart';

class MyCart extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: Text('Cart', style: Theme.of(context).textTheme.headline1),
       backgroundColor: Colors.white,
     ),
     body: Container(
       color: Colors.yellow,
       child: Column(
         children: [
           Expanded(
             child: Padding(
               padding: const EdgeInsets.all(32),
               child: _CartList(),
             ),
           ),
           Divider(height: 4, color: Colors.black),
           _CartTotal()
         ],
       ),
     ),
   );
 }
}

class _CartList extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   var itemNameStyle = Theme.of(context).textTheme.headline6;
   //var cart = Provider.of<CartModel>(context);
   var cart = context.watch<CartModel>();

   return ListView.builder(
     itemCount: cart.items.length,
     itemBuilder: (context, index) => ListTile(
       leading: Icon(Icons.done),
       title: Text(cart.items[index].name, style: itemNameStyle)
     ),
   );
 }
}

class _CartTotal extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   var hugeStyle =
   Theme.of(context).textTheme.headline1.copyWith(fontSize: 48);

   return SizedBox(
     height: 200,
     child: Center(
       child: Row(
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
           Text('\$${context.watch<CartModel>().totalPrice}', style: hugeStyle),
           SizedBox(width: 24),
           FlatButton(
             onPressed: () {
               Scaffold.of(context).showSnackBar(
                   SnackBar(content: Text('Buying not supported yet.')));
             },
             color: Colors.white,
             child: Text('BUY'),
           ),
         ],
       ),
     ),
   );
 }
}
​

context.watch<CartModel>() が、notifyListeners() の通知をキャッチして変更を反映します。

main.dartを以下のように少し修正して再起動すると、カートページがうまく機能しています。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:provider_shopper_tkugimot/models/CartModel.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';
import 'package:provider_shopper_tkugimot/screens/CartScreen.dart';
import 'package:provider_shopper_tkugimot/screens/CatalogScreen.dart';

void main() {
 runApp(MyApp());
}

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   // Using MultiProvider is convenient when providing multiple objects.
   return MultiProvider(
     providers: [
       // In this sample app, CatalogModel never changes, so a simple Provider
       // is sufficient.
       Provider(create: (context) => CatalogModel()),
       // CartModel is implemented as a ChangeNotifier, which calls for the use
       // of ChangeNotifierProvider. Moreover, CartModel depends
       // on CatalogModel, so a ProxyProvider is needed.
       ChangeNotifierProxyProvider<CatalogModel, CartModel>(
         create: (context) => CartModel(),
         update: (context, catalog, cart) {
           cart.catalog = catalog;
           return cart;
         },
       ),
     ],
     child: MaterialApp(
       title: 'Provider Demo',
       initialRoute: '/catalog',
       routes: {
         '/catalog': (context) => MyCatalog(),
         '/cart': (context) => MyCart(),
       },
     ),
   );
 }
}

画像4


参考にしてるサンプルと同じように、widget testは書いた方が良さそうです。


デザインを整えるためにMaterialDesignのthemeを導入します。

# common/theme.dart
import 'package:flutter/material.dart';

final appTheme = ThemeData(
 primarySwatch: Colors.yellow,
 textTheme: TextTheme(
   headline1: TextStyle(
     fontFamily: 'Corben',
     fontWeight: FontWeight.w700,
     fontSize: 24,
     color: Colors.black,
   ),
   headline2: TextStyle(
     fontFamily: 'Corben',
     fontWeight: FontWeight.w700,
     fontSize: 12,
     color: Colors.black,
   ),
 ),
);


# main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:provider_shopper_tkugimot/models/CartModel.dart';
import 'package:provider_shopper_tkugimot/models/CatalogModel.dart';
import 'package:provider_shopper_tkugimot/screens/CartScreen.dart';
import 'package:provider_shopper_tkugimot/screens/CatalogScreen.dart';

import 'common/theme.dart';

void main() {
 runApp(MyApp());
}

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   // Using MultiProvider is convenient when providing multiple objects.
   return MultiProvider(
     providers: [
       // In this sample app, CatalogModel never changes, so a simple Provider
       // is sufficient.
       Provider(create: (context) => CatalogModel()),
       // CartModel is implemented as a ChangeNotifier, which calls for the use
       // of ChangeNotifierProvider. Moreover, CartModel depends
       // on CatalogModel, so a ProxyProvider is needed.
       ChangeNotifierProxyProvider<CatalogModel, CartModel>(
         create: (context) => CartModel(),
         update: (context, catalog, cart) {
           cart.catalog = catalog;
           return cart;
         },
       ),
     ],
     child: MaterialApp(
       title: 'Provider Demo',
       theme: appTheme,
       initialRoute: '/catalog',
       routes: {
         '/catalog': (context) => MyCatalog(),
         '/cart': (context) => MyCart(),
       },
     ),
   );
 }
}


画像5


画像6


一応これで一通り終わりかなーと思いますが、最後に「BUY」を押した後のメッセージを変更して、カートをクリアする処理を追加しようと思います。

# CartModel.dart
class CartModel extends ChangeNotifier {
 /// The private field backing [catalog].
 CatalogModel _catalog;

 /// Internal, private state of the cart. Stores the ids of each item.
 final List<int> _itemIds = [];

 /// The current catalog. Used to construct items from numeric ids.
 CatalogModel get catalog => _catalog;

 set catalog(CatalogModel newCatalog) {
   assert(newCatalog != null);
   assert(_itemIds.every((id) => newCatalog.getById(id) != null),
   'The catalog $newCatalog does not have one of $_itemIds in it.');
   _catalog = newCatalog;
   // Notify listeners, in case the new catalog provides information
   // different from the previous one. For example, availability of an item
   // might have changed.
   notifyListeners();
 }

 /// List of items in the cart.
 List<Item> get items => _itemIds.map((id) => _catalog.getById(id)).toList();

 /// The current total price of all items.
 int get totalPrice =>
     items.fold(0, (total, current) => total + current.price);

 /// Adds [item] to cart. This is the only way to modify the cart from outside.
 void add(Item item) {
   _itemIds.add(item.id);
   // This line tells [Model] that it should rebuild the widgets that
   // depend on it.
   notifyListeners();
 }

 void clear() {
   _itemIds.clear();
   notifyListeners();
 }
}​
# CartModelTest.dart
void main() {
 test('adding item increases total cost', () {
   final cart = CartModel();
   final startingPrice = cart.totalPrice;
   cart.add(Item(1, 'Summer'));
   cart.addListener(() {
     expect(cart.totalPrice, greaterThan(startingPrice));
   });
 });

 test('clear all items', () {
   final cart = CartModel();
   cart.add(Item(1, 'Summer'));
   cart.addListener(() {
     expect(cart.totalPrice, 42);
   });

   cart.clear();
   expect(cart.totalPrice, 0);
   expect(cart.items.length, 0);
 });
}
# CartScreen.dart
...
class _CartTotal extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   var hugeStyle =
   Theme.of(context).textTheme.headline1.copyWith(fontSize: 48);

   return SizedBox(
     height: 200,
     child: Center(
       child: Row(
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
           Text('\$${context.watch<CartModel>().totalPrice}', style: hugeStyle),
           SizedBox(width: 24),
           FlatButton(
             onPressed: () {
               Scaffold.of(context).showSnackBar(
                   SnackBar(content: Text('Payment has been received.')));
               context.read<CartModel>().clear(); # ここを追加
             },
             color: Colors.white,
             child: Text('BUY'),
           ),
         ],
       ),
     ),
   );
 }
}


画像7

画像8


以上です。

まあ、大枠は分かったような気がしますが、CartがCatalogに依存してる辺りが若干モヤっとしているのと、デザイン周りがやっぱりまださっぱりです。

次は地道にCookbook触ってみます。


この記事が気に入ったらサポートをしてみませんか?