56
33

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 3 years have passed since last update.

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

Last updated at Posted at 2020-05-18

はじめに

以前、 Flutter初見が5日間のハッカソンでアプリ開発してきた という内容で記事を書いたところたくさんの方に見ていただけたようで、Flutterのやっていきが加速しました。

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

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

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

Provider.of<Hoge>(context).hoge

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

無駄な再描画が起こらないようにするためには、monoさんの Stateful Widget のパフォーマンスを考慮した正しい扱い方 で言及されているように、 Stateは末端に追いやる が正解だと思いますが、

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

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

ChangeNotifier概要

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

公式ドキュメントはこちら

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

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

具体例

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

class Counter extends ChangeNotifier {
  int count = 0;

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

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

使用する側では、

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の依存関係を追加してあげる必要があります。

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

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

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

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

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

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

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

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

ここで

CountWidgetもビルドされる

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

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

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

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

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

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

Consumerでラップする

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

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

今回であれば,

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

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

[Flutter] package:provider の各プロバイダの詳細 も見てみると理解が深まるかもしれません。

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

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

このコードは

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

と書いても同じです。

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

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

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

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

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

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

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

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

結論

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

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

ことがわかりました。

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

再描画を最小限に抑える

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

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

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

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

おわりに

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

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

追記

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

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

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

56
33
1

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
56
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?