【Flutter】ChangeNotifierの再描画範囲を調べてみた

## はじめに

以前、 [Flutter初見が5日間のハッカソンでアプリ開発してきた]([https://qiita.com/hohohoris/items/dd0df09c5b0aeb3d87da](https://qiita.com/hohohoris/items/dd0df09c5b0aeb3d87da)) という内容で記事を書いたところたくさんの方に見ていただけたようで、Flutterのやっていきが加速しました。

ありがとうございましたmm

今回は、そのハッカソンアプリのリファクタをする際に気になったことを簡単に調べて検証した記事になります。

Flutterの状態管理でChangeNotifierを使っている場合、ChangeNotifierからの変更通知を

```dart
Provider.of<Hoge>(context).hoge
```

のように受け取ると思うのですが、無駄な再描画がかかってしまい変な挙動になってしまうことがありました。

無駄な再描画が起こらないようにするためには、monoさんの [Stateful Widget のパフォーマンスを考慮した正しい扱い方]([https://medium.com/flutter-jp/state-performance-7a5f67d62edd](https://medium.com/flutter-jp/state-performance-7a5f67d62edd)) で言及されているように、 `Stateは末端に追いやる` が正解だと思いますが、

そもそもStateに変更があった場合に、どこまで更新されるかを知っておかないと今後意図せぬ挙動を引き起こしかねません。

そこで、恒例のCountアプリを例に再描画される範囲を検証してみました。

## ChangeNotifier概要

ここでは詳しく解説はしませんが、ChangeNotifierに関して少しだけ触れます。

公式ドキュメントは[こちら](https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple)

ChangeNotifierは、いわゆる **ObserverパターンのObservable** です。

ChangeNotifierを継承またはmixinしたクラスで状態を持ち、それらの値を更新した後に `notifyListeners()` を呼ぶことで、Observerに変更を通知することができます。

### 具体例

後ほどの検証でもでてきますが、Countアプリを例にした場合はこんなふうに書きます。

```dart
class Counter extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners();
  }
}
```

このように、状態(count) を持ち、 状態に変更があった場合に `notifyListeners()`を呼ぶ書き方です。

使用する側では、

```dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => Counter(),
      child: MaterialApp(
        home: HomePage(),
      ),
    );
  }
}
```

このように `ChangeNotifierProvider` を用い、子WidgetがCounterインスタンスにアクセスできるようにしてあげます。

ちなみに `ChangeNotifier` はFlutter SDKに標準で備わっていますが、 `ChangeNotifierProvider` を使う場合は [provider package]([https://pub.dev/packages/provider](https://pub.dev/packages/provider))の依存関係を追加してあげる必要があります。

HomePageでChangeNotifierの状態(count)を監視したり、単発でメソッドを呼び出すコードはこちらです。

```dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Text('${Provider.of<Counter>(context).count}'),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Provider.of<Counter>(context, listen: false).increment(),
      ),
    );
  }
}
```

`Provider.of<Counter>(context).count` 

のように、型を指定しつつ、状態にアクセスするだけでObserve完了です。

ChangeNotifierで notifyListeners() が呼ばれるとTextがリビルドされます。

onPressedのように、イベントとしてChanegNotifierのメソッドにアクセスしたい場合は

`Provider.of<Counter>(context, listen: false).increment()` と、listenをfalseにすることで無駄な再描画はされずに済みます。

## ChangeNotifierの再描画範囲

ChangeNotifierの概要について触れたところで本題に入ります。

再描画が走るとき、その範囲ははたしてTextだけなのでしょうか？

答えはNoです。

![all.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/cff3fab4-971e-95d1-7f03-754dbebfedc3.gif)

Gifのように、HomePageそのものがリビルドされており、Scaffold,AppBarにまで影響が及んでいます。

自分はそのあたりの理解が薄かったため、いくつかのパターンを試して再描画範囲に関して検証してみました。

### Widgetを切り出し、Stateを末端に追いやってみる

再描画が走るTextだけ、別Widgetに切り出してみました。

```dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // 略
      body: const CountWidget(),
      // 略
    );
  }
}
　
// Stateを別Widgetに切り出す
class CountWidget extends StatelessWidget {
  const CountWidget();

  @override
  Widget build(BuildContext context) {
    return Text('${Provider.of<Counter>(context).count}');
  }
}
```

![countWidget.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/441e680e-7f55-96f3-3a8f-61a2fa4b5dd4.gif)

FloatingActionボタンを押すと今度は `CountWidget` 以下がリビルドされました。

ここで

**CountWidgetもビルドされる**

ということに疑問を抱きました。

### 末端に追いやったStateに再描画する必要がないWidgetを置く

```dart
class CountWidget extends StatelessWidget {
  const CountWidget();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Count'),
        Text('${Provider.of<Counter>(context).count}'),
      ],
    );
  }
}
```

![counterWidget2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/23dac3db-a7c3-df84-a8cd-5b0a97c59770.gif)

今回は `Text('Count')` はパフォーマンス的には再描画してほしくありませんが、 `CountWidget` 自体が再描画されているので、例に違わず再描画されてしまいます。

回避策としては `const` をつけることがあげられますが、 notifyListeners() が呼ばれたときに再描画してほしくないWidgetがconstを付けられないこともあります。

根本的に仕組みを知ることで解決したいので、次のパターンを試してみました。

### Consumerでラップする

Providerでは、提供されたインスタンスにアクセスするためには、

Providerよりも子である `BuildContext` が必要になります。

今回であれば,

ChangeNotifierProviderを用いて Counterインスタンスを提供している `MyApp` 以下のBuildContextを `Provider.of<T>(context)` のcontextに渡してあげる必要があります。

Consumerは、それが得られない場合に有効です。

[[Flutter] package:provider の各プロバイダの詳細]([https://qiita.com/kabochapo/items/a90d8438243c27e2f6d9#consumer](https://qiita.com/kabochapo/items/a90d8438243c27e2f6d9#consumer)) も見てみると理解が深まるかもしれません。

```dart
class CountWidget extends StatelessWidget {
  const CountWidget();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Count'),
        Consumer<Counter>(
          builder: (_, counter, __) {
            return Text('${counter.count}');
          },
        ),
      ],
    );
  }
}
```
![consumer.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/ab660223-8b45-f1f0-57c5-b719dbe3189c.gif)

この場合は、Consumerと、その内部のTextのみが再描画されています。

このコードは

```dart
Consumer<Counter>(
  builder: (childContext, _, __) {
    return Text('${Provider.of<Counter>(childContext).count}');
    },
  ),
```

と書いても同じです。

この時点でなんとなくわかってきましたが、念のためもう1パターン検証してみます。

### 末端に追いやったWidgetのrootにConsumerを置く

```dart
class CountWidget extends StatelessWidget {
  const CountWidget();

  @override
  Widget build(BuildContext context) {
    return Consumer<Counter>(
      builder: (_, counter, __) {
        return Column(
          children: <Widget>[
            Text('Count'),
            Text('${counter.count}'),
          ],
        );
      },
    );
  }
}
```
![rootConsumer.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/f90fb6f1-c2ed-4d86-d45e-a82035644d89.gif)

この場合も、Consumer以下が再描画されます。

`Text('Count')` は再描画される必要がないため、以下のように書いてみると

```dart
class CountWidget extends StatelessWidget {
  const CountWidget();

  @override
  Widget build(BuildContext context) {
    return Consumer<Counter>(
      builder: (_, counter, child) {
        return Column(
          children: <Widget>[
            child,
            Text('${counter.count}'),
          ],
        );
      },
      child: Text('Count'),
    );
  }
}
```
![rootConsumer2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/435438/29732cbc-227b-d17f-b6e8-ce4718e274bc.gif)

再描画範囲はConsumer内のTextのみに限定されます。

これは、Consumerの `child` で指定されたWidgetが先にビルドされ、builderの `child` に渡ってくるためです。

## 結論

上記のように試してみた結果、再描画範囲は

**Provider.of<T>(context)で指定したcontextをもつWidget以下がリビルドされる** 

ことがわかりました。

つまり、無駄な再描画を抑えたいのであれば、使用するcontextに注意を払うことが大事です。

## 再描画を最小限に抑える

冒頭で触れた monoさんの記事に記載されていることが全てではあるのですが、

ChangeNotifierをlistenする場所を末端に追いやるか、Consumerをうまく使うことで最小限に抑えられそうです。

```dart
 @override
  Widget build(BuildContext context) {
    final count = Provider.of<Counter>(context).count 
    return // 略
  }
```

と、buildメソッドの冒頭でlisten trueで変数に代入しているサンプルをたまに見かけますが、無駄な再描画を抑えるのであればこの書き方は避けた方がよいかもしれませんね(listen falseであれば問題ないと思います)。

## おわりに

ChangeNotifierはUIとロジックを分けることができ、便利ですが再描画の範囲を理解していないことで意図しない挙動に陥ってしまうことがあったため調査してみました。

今回の記事で同じような状況の方の参考になれば幸いです。

## 追記

ChangeNotifierをどの単位で持つべきか、いまだによくわかっていないので、よければコメントいただければ幸いです。

初めはModel単位（ブログであれば ArticleやCategory等の単位）で持ってしまっていたのですが、最近のリファクタで画面単位で持つようにしました。

現状画面単位で特に支障はないのですが、デメリットもあったりするんでしょうか...？