ValueNotifierを使うメリットとその使い方

## 「とりあえずChangeNotifier」は本当に最適解？
「とりあえずChangeNotifierでできるからChangeNotifierで」という理由でChangeNotifierを使っていませんか？最近までの私はそうでした。しかし、ValueNotifierで十分な場合も実は結構多く、こちらを使った方が状態管理・通知クラスをよりシンプルに保てることに気づいたので、その良さをまとめてみました。

**この記事ではValueNotifierとChangeNotifierを比較しつつ、ValueNotifierを使うメリットと使い方を説明します。**（合わせて、freezedやstate_notifierとの関係性についても紹介します) この記事を読んだあなたは、今日からさっそくValueNotifierを使いこなせるはずです！

## ValueNotifierとは
ValueNotifierは、ChangeNotifierを継承しValueListenableを実装したクラスで、実装は非常にシンプルです。 **ChangeNotfierを継承しているので、ChangeNotifierと同じように使えますが、状態変数を一つしか持ちません。**

https://github.com/flutter/flutter/blob/1aafb3a8b9/packages/flutter/lib/src/foundation/change_notifier.dart#L276

```dart
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](https://pub.dev/packages/state_notifier)にも共通します。こちらが好きな方はこちらを使うべきだと思います（理由については[こちらで述べます。](#発展-packagestate_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


![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/164020/e9cf6dfc-6bba-de59-565f-05a21033582e.png)

主な機能は以下の通りです。

- カートに入っている商品がListViewで表示される
- 削除ボタンを押すとその商品がListViewから削除され、カートの合計金額が再計算される
- 空になると「カートは空です」と表示される


### 実装1. Notifier
まず、以下のように３つクラスを作成します。

- Itemクラス
- Cartクラス
- CartNotifier: Cartの状態を管理するValueNotifier

また、repository.get()からはCartのインスタンスが取得できるとします。

```dart
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を使う場合

```dart
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に明確に優っている部分かと思います。

```dart
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と併用する場合については、以下を開いてください

<details><summary>package:providerと併用する場合</summary><div>

コードの全体は https://github.com/TetsuFe/value_notifier_sample/tree/provider にあります。

providerは、4.3.2+2 を使っています。違いは以下の２点です。

- ChangeNotifierProviderを使ってCartNotifierをCartViewに渡す
  - CartViewでChangeNotifierProviderを使ってもいいです
- ValueListenableBuilderの代わりにConsumerを使う

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

```dart
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}'),
            ],
          ),
        );
      },
    );
  }
}

```


</div></details>


## package:state_notifierを使う場合
state_notifierを使う場合のサンプルも同様に書いてみました。ほぼ変わらないので、コードのリンクだけ貼っておきます。

https://github.com/TetsuFe/value_notifier_sample/tree/state_notifier

## package:freezedと併用する場合
ValueNotifierの状態変数がクラスの場合、一部のメンバのみを更新したいときがあると思います。そんなときに便利なのが、copyWith()（一部のフィールドを更新した新しいインスタンスを返すメソッド）などのメソッドが簡単に実装できる[freezedパッケージ](https://pub.dev/packages/freezed)です。freezedを使うことで、以下のように書くことができます。

```dart
@freezed
abstract class Person with _$Person {
  factory Person({ String name, int age }) = _Person;
}
```

```dart
person = Person("Mike", 4);
personNotifier.value = person.copyWith(age: 5); // person.name = "Mike", person.age = 5 が代入される
```

この例のように、 **copyWith()は一部のフィールドを更新したいときに便利であり、かつ新しいインスタンスを返してくれるので古いインスタンス（状態）のことを気にする必要がなく、より安全です。**

ここでは簡単な紹介にとどめますが、気になる方は以下の記事が参考になるかと思います。

https://dev.classmethod.jp/articles/flutter_freezed_introduction/

**ちなみに、freezedを無理に採用する必要はありません。** freezedを使うデメリットはほとんどないと思いますが、ないわけではないです。例えば、コードジェネレータ系の依存ライブラリのバージョンによってエラーが出てしまい、時間を取られたことがありました。


## 発展: package:state_notifier
[package:state_notifier](https://pub.dev/packages/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](https://twitter.com/tetsufe_)またはコメントからお願いします。


## 参考
- [Flutterの状態管理手法の選定](https://medium.com/flutter-jp/state-1daa7fd66b94)
- [https://medium.com/coding-with-flutter/flutter-state-management-setstate-bloc-valuenotifier-provider-2c11022d871b](https://medium.com/coding-with-flutter/flutter-state-management-setstate-bloc-valuenotifier-provider-2c11022d871b)
- 実装の参考にしたリポジトリ
  - https://github.com/flutter/samples/blob/master/provider_shopper/
  - https://github.com/mono0926/wdb106-flutter