「とりあえずChangeNotifier」は本当に最適解?
「とりあえずChangeNotifierでできるからChangeNotifierで」という理由でChangeNotifierを使っていませんか?最近までの私はそうでした。しかし、ValueNotifierで十分な場合も実は結構多く、こちらを使った方が状態管理・通知クラスをよりシンプルに保てることに気づいたので、その良さをまとめてみました。
この記事ではValueNotifierとChangeNotifierを比較しつつ、ValueNotifierを使うメリットと使い方を説明します。(合わせて、freezedやstate_notifierとの関係性についても紹介します) この記事を読んだあなたは、今日からさっそくValueNotifierを使いこなせるはずです!
ValueNotifierとは
ValueNotifierは、ChangeNotifierを継承しValueListenableを実装したクラスで、実装は非常にシンプルです。 ChangeNotfierを継承しているので、ChangeNotifierと同じように使えますが、状態変数を一つしか持ちません。
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
/// Creates a [ChangeNotifier] that wraps this value.
ValueNotifier(this._value);
/// The current value stored in this notifier.
///
/// When the value is replaced with something that is not equal to the old
/// value as evaluated by the equality operator ==, this class notifies its
/// listeners.
@override
T get value => _value;
T _value;
set value(T newValue) {
if (_value == newValue)
return;
_value = newValue;
notifyListeners();
}
ValueNotifierを使うメリット
ValueNotifierを使うメリットは主に以下のような点かと思います。ここでは、特にChangeNotifierと比較してのメリットをご紹介します。
- notifyできる 状態変数が一つに強制されるので、状態管理クラスがシンプルに保たれる
-
notifyListeners()の呼び忘れによるバグが発生しない
- valueを更新すると、notifyListeners()が自動的に呼ばれるため
また、これらのことはpackage:state_notifierにも共通します。こちらが好きな方はこちらを使うべきだと思います(理由についてはこちらで述べます。)
ValueNotifierの使い所
単純に、「状態変数が一つのとき」はValueNotifierが使えます。特に、今後も状態変数が増えないような想定の場合は、ValueNotifierを使うべきだと思います。
例えば、以下のような場合です。
- API・DBからまとめて一つのデータを取得するとき
- shared_preferenceで値を取得・更新するとき
と言われてもイマイチ想像ができない方もいるかと思いますので、これからサンプルでValueNotifierの使い方をご紹介します。
ValueNotifierの使い方
(環境: Flutter Channel stable, 1.22.4)
ここからはサンプルを通してValueNotifierの使い方を説明していきます。以下のようなUIのショッピングアプリを作ります。
GitHubリポジトリは以下です。
https://github.com/TetsuFe/value_notifier_sample
主な機能は以下の通りです。
- カートに入っている商品がListViewで表示される
- 削除ボタンを押すとその商品がListViewから削除され、カートの合計金額が再計算される
- 空になると「カートは空です」と表示される
実装1. Notifier
まず、以下のように3つクラスを作成します。
- Itemクラス
- Cartクラス
- CartNotifier: Cartの状態を管理するValueNotifier
また、repository.get()からはCartのインスタンスが取得できるとします。
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
import 'cart_repository.dart';
class Item{
int price;
String name;
}
@immutable
class Cart {
Cart(this.items);
final List<Item> items;
int get totalPrice =>
items.length > 0 ? items.map((a) => a.price).reduce((a, b) => a + b) : 0;
String get totalPriceWithUnit => '$totalPrice円';
Cart remove(Item item) {
return Cart(
this.items.where((e) => !e.equals(item)).toList(),
);
}
}
class CartNotifier extends ValueNotifier<Cart> {
CartNotifier({@required this.repository}) : super(null) {
init();
}
void init() async {
value = await repository.get();
}
void remove(Item item) {
this.value = value.remove(item);
}
final CartRepository repository;
}
実装2. UI
そして、CartNotifierをUIに表示するには、以下のように書きます。
ValueListenableBuidlerを使う場合
class CartView extends StatelessWidget {
final cartNotifier = CartNotifier(repository: CartRepository());
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Cart>(
valueListenable: cartNotifier,
builder: (context, cart, _) {
if (cart == null) {
return const CircularProgressIndicator();
}
return Center(
child: Column(
children: [
cart.items.length == 0
? Text('カートは空です。')
: ListView.builder(
shrinkWrap: true,
itemCount: cart.items.length,
itemBuilder: (context, index) => ListTile(
title: Text('${cart.items[index].name}'),
subtitle: Text('${cart.items[index].priceWithUnit}'),
trailing: FlatButton(
onPressed: () {
cartNotifier.remove(cart.items[index]);
},
color: Theme.of(context).accentColor,
child: Text('削除'),
textColor: Colors.white,
),
),
),
Text('合計金額: ${cart.totalPriceWithUnit}'),
],
),
);
},
);
}
}
実装3. テスト
以下のような感じで書けば、UIを経由せずValueNotifierの操作を通して、カートの更新と削除をテストすることができます。 UIを経由しないため、いちいちアプリのビルドを待ってからデバッグしなくても良くなります。 こういったUIに依存しないテストをかけるのは、ChangeNotifierやValueNotifierがStatefulWidgetに明確に優っている部分かと思います。
import 'package:flutter_test/flutter_test.dart';
import 'package:value_notifier_sample/cart.dart';
import 'package:value_notifier_sample/cart_notifier.dart';
import 'package:value_notifier_sample/cart_repository.dart';
import 'package:value_notifier_sample/item.dart';
void main() {
test('cart value notifier', () async {
final cartNotifier = CartNotifier(repository: CartRepository());
final cart = Cart([Item(1, 'grape', 300), Item(2, 'orange', 100)]);
Cart lastValue;
cartNotifier.addListener(() {
lastValue = cartNotifier.value;
});
cartNotifier.value = cart;
expect(lastValue, cart);
cartNotifier.remove(Item(1, 'grape', 300));
expect(lastValue.items.map((e) => e.name), ['orange']);
});
}
package:providerと併用する場合
package:providerと併用する場合については、以下を開いてください
package:providerと併用する場合
コードの全体は https://github.com/TetsuFe/value_notifier_sample/tree/provider にあります。
providerは、4.3.2+2 を使っています。違いは以下の2点です。
- ChangeNotifierProviderを使ってCartNotifierをCartViewに渡す
- CartViewでChangeNotifierProviderを使ってもいいです
- ValueListenableBuilderの代わりにConsumerを使う
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:value_notifier_sample/cart_notifier.dart';
import 'package:value_notifier_sample/cart_repository.dart';
import 'cart_page.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
// ここでChangeNotifierProviderを使う
home: ChangeNotifierProvider(
create: (_) => CartNotifier(repository: CartRepository()),
child: CartPage(),
),
);
}
}
class CartPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('カート'),
),
body: CartView(),
);
}
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'cart_notifier.dart';
class CartView extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ValueListenableBuilderの代わりにConsumerを使う
return Consumer<CartNotifier>(
builder: (context, cartNotifier, _) {
final cart = cartNotifier.value;
if (cart == null) {
return const CircularProgressIndicator();
}
return Center(
child: Column(
children: [
cart.items.length == 0
? Text('カートは空です。')
: ListView.builder(
shrinkWrap: true,
itemCount: cart.items.length,
itemBuilder: (context, index) => ListTile(
title: Text('${cart.items[index].name}'),
subtitle: Text('${cart.items[index].priceWithUnit}'),
trailing: FlatButton(
onPressed: () {
cartNotifier.remove(cart.items[index]);
},
color: Theme.of(context).accentColor,
child: Text('削除'),
textColor: Colors.white,
),
),
),
Text('合計金額: ${cart.totalPriceWithUnit}'),
],
),
);
},
);
}
}
package:state_notifierを使う場合
state_notifierを使う場合のサンプルも同様に書いてみました。ほぼ変わらないので、コードのリンクだけ貼っておきます。
package:freezedと併用する場合
ValueNotifierの状態変数がクラスの場合、一部のメンバのみを更新したいときがあると思います。そんなときに便利なのが、copyWith()(一部のフィールドを更新した新しいインスタンスを返すメソッド)などのメソッドが簡単に実装できるfreezedパッケージです。freezedを使うことで、以下のように書くことができます。
@freezed
abstract class Person with _$Person {
factory Person({ String name, int age }) = _Person;
}
person = Person("Mike", 4);
personNotifier.value = person.copyWith(age: 5); // person.name = "Mike", person.age = 5 が代入される
この例のように、 copyWith()は一部のフィールドを更新したいときに便利であり、かつ新しいインスタンスを返してくれるので古いインスタンス(状態)のことを気にする必要がなく、より安全です。
ここでは簡単な紹介にとどめますが、気になる方は以下の記事が参考になるかと思います。
ちなみに、freezedを無理に採用する必要はありません。 freezedを使うデメリットはほとんどないと思いますが、ないわけではないです。例えば、コードジェネレータ系の依存ライブラリのバージョンによってエラーが出てしまい、時間を取られたことがありました。
発展: package:state_notifier
package:state_notifierは、ValueNotifierとほぼ同じように使えつつ、機能がいくつか追加されているValueNotifierの強化版といっていいパッケージです。特に抵抗がなければ、ValueNotifierよりもこちらを使うべきだと思います。(しかしもちろん、この記事で学んだことは無駄にはなりません!)
Flutter非依存、LocatorMixinによるDIが可能、パフォーマンスの改善などの特徴があり、
また、大きな違いとして、状態変数が@protectedで宣言されているため、状態変数を不用意に書き換えようとすると警告が出ます。これにより、状態変数を安全にImmutableに保つことが可能です。
色々と便利なのですが、唯一の欠点はFlutter公式パッケージではないという点でしょうか。LocatorMixinに関しては一部機能がflutter web(まだbetaですが)で動かないという問題があります。
より詳しく学びたい方は、以下の記事が参考になるかと思います。
https://itome.team/blog/2020/05/flutter-state-notifier-provider/
https://medium.com/flutter-jp/state-1daa7fd66b94
まとめ
- ValueNotifierも使える場面は多い
- 状態変数がシンプルになるなど良い点がある
- package:freezedを使うと状態変数の扱いが楽になる
- 外部パッケージのpackage:state_notifierもオススメ
間違っている点などありましたら、僕のTwitterまたはコメントからお願いします。