Flutter/Dart

【Flutter/Dart】状態管理 Providerパッケージ

asoacasio

Providerは状態管理ライブラリの1つで、Flutter開発でよく利用されます。

特徴

InheritedWidgetのラッパーであり、ChangeNotifierとの親和性が高いライブラリです。

Providerは状態の変更をChangeNotifierを用いて通知し、InheritedWidgetを使って効率的にWidgetツリー全体に伝搬させます。

Providerを利用すると、アプリ内のどこからでも状態を取得したり更新したりでき、それに合わせて画面を適切に再描画することができます。

*(もちろんProviderはChangeNotifier以外でも使えます。組み合わせて使用すると効果的なのでその方法についてまとめたいと思います)

InheritedWidget

Widgetツリー全体で共有されるデータを保持します。

ChangeNotifier

Listenerに変更を通知できます。

ChangeNotifierを継承したクラスであれば、そのインスタンスが変更されたときに自動的にListenerに通知されます。複数のWidgetに状態を通知したい時に効果的に利用されます。

Provider

InheritedWidgetは参照先が変わった時に再描画しますが、参照先のデータが変わった時に再描画する機能がありません。

ProviderはInheritedWidgetを拡張したクラスで、Provider.ofメソッドを利用してアプリ全体から状態オブジェクトにアクセスできます。

更にChangeNotifierを用いて変更の通知を受け、Providerは必要なWidgetだけを再描画させます。

ProviderはChangeNotifierとセットで使うことで効果的な状態管理ができます。

メリット

  • 効率的な再描画:状態を変更すると、依存関係のあるWidgetを再構築します。不必要なWidgetの再描画はしません。
  • シンプルな状態管理:Provider.ofやConsumerといったシンプルなAPIを介して状態を参照・更新できます。

効果的に利用されるシナリオ

ユーザ認証

例えばログイン状態を管理する場合に、効果的です。

ChangeNotifierを継承したクラスは現在のユーザー情報を保持し、ログインまたはログアウトが実行された際にリスナーにその変更を通知します。これにより関連するすべてのUI部品はその新しい状態に基づいて更新されます。

テーマ設定

画面のテーマ設定をする場合に、依存関係のあるWidgetを一括更新できます。

ChangeNotifierを継承したクラスは選択中のテーマを保持し、新しいテーマを選択した際にリスナーにその変更を通知します。これにより各画面は新しいテーマが反映されます。

必要なWidgetだけ再描画する

再描画するWidgetと②再描画しないWidgetが明確に分かるようにサンプルコードを書いてみました。

①再描画するWidgetにはリスナーを設けてChangeNotifierを継承したクラスの情報が受け取れるようになっています。

一方、②再描画しないWidgetはリスナーをセットしていないので再描画がかかることはありません。

画面右下の+ボタンを押したタイミングで、notifyListeners()によってリスナーへ通知しています。

import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class ProviderPage extends StatelessWidget {
  const ProviderPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => Counter(),
      child: MaterialApp(
        home: Scaffold(
            appBar: AppBar(title: const Text('Provider Example')),
            body: const Center(child: RebuildWidget()),
            floatingActionButton: const NoRebuildWidget()),
      ),
    );
  }
}

/// リスナーをセットしていないので再描画がかからないWidget
class NoRebuildWidget extends StatelessWidget {
  const NoRebuildWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    log("+ボタンの描画.");
    return FloatingActionButton(
      onPressed: () {
        context.read<Counter>().increment();
      },
      tooltip: 'Increment',
      child: const Icon(Icons.add),
    );
  }
}

/// リスナーをセットしているので再描画がかかるWidget
class RebuildWidget extends StatelessWidget {
  const RebuildWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<Counter>(context);
    log("テキストの描画. カウンターの値: ${counter.getCounter}");
    return Text('Counter Value: ${counter.getCounter}');
  }
}

class Counter with ChangeNotifier {
  int _counter = 0;

  int get getCounter => _counter;

  void increment() {
    _counter++;
    log("+ボタンを押す...");
    // 状態が変化した時にリスナーに通知する
    // (ChangeNotifierクラスのメソッド)
    notifyListeners();
  }
}

ログを見てみます。

ページの初期化時だけ+ボタンは描画され、その後、+ボタンを押しても再描画されていないことが分かります。

一方で、テキストはボタンを押すたびに再描画がかかっています。

[log] テキストの描画. カウンターの値: 0
[log] +ボタンの描画.
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 1
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 2
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 3

メソッドを使い分けて再描画する

先ほどのサンプルコードではcontext.read()を利用しましたが、状態を取得するメソッドには

  • context.read()
  • context.watch()
  • context.select()

があります。

context.read()

要求した情報を1回だけ取得します。取得後は情報が変わっても通知されません。

一般的には、ボタンのonPressedなどのコールバック内で状態を変更するために使います。

メッセージを友達から受信して内容を確認しました。その後、友達がメッセージの一部を変更しましたが、それについては知ることができないようなイメージです。

context.watch()

要求した情報を監視続けます。情報が変わる度に通知を受けます。

アップルパイがちゃんと想定通りに焼けているか何度もチェックするようなイメージです。

context.select()

特定の情報がけ監視続けます。

スポーツ観戦している時に、特定の選手だけ注目して見ているようなイメージです。

これらの3つのメソッドの機能の差が分かるようにサンプルコードを書いてみます。

ChangeNotifierを継承したCounterクラスでは、+ボタンが押される度にカウンターがインクリメントされます。カウンターが3の倍数になるとアルファベットのプロパティにaが足されます。

  • カウンター表示のWidgetは、context.watch()を利用してどの情報が変わっても再描画します。
  • アルファベット表示のWidgetは、context.select()を利用して、アルファベットが変わった時だけ再描画します。
  • +ボタンはcontext.read()を利用して、ボタンが押された時だけ再描画します。その後、Counterクラスの情報が変わっても再描画しません。
import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class ProviderPage extends StatelessWidget {
  const ProviderPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => Counter(),
      child: MaterialApp(
        home: Scaffold(
          appBar: AppBar(title: const Text('Provider Example')),
          body: Stack(
            children: <Widget>[
              Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: const [
                    WatchWidget(),
                    SelectWidget(),
                  ],
                ),
              ),
            ],
          ),
          floatingActionButton: const ReadWidget(),
        ),
      ),
    );
  }
}

/// ボタンが押された時に1度だけ再描画するWidget
class ReadWidget extends StatelessWidget {
  const ReadWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    log("+ボタンの描画.");
    return FloatingActionButton(
      onPressed: () {
        context
            .read<Counter>()
            .increment(); // ボタンが押された時にCounterクラスのincrementメソッドを呼び出す.
      },
      tooltip: 'Increment',
      child: const Icon(Icons.add),
    );
  }
}

/// 情報が変われば再描画するWidget
class WatchWidget extends StatelessWidget {
  const WatchWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final counter = context.watch<Counter>(); // 常に監視.
    log("テキストの描画. カウンターの値: ${counter.getCounter}");
    return Text('Counter Value: ${counter.getCounter}');
  }
}

/// 条件によって再描画するWidget
class SelectWidget extends StatelessWidget {
  const SelectWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // _alphabetが変更された時だけ再描画する.
    final alphabet = context
        .select<Counter, String>((counter) => counter.getAlphabet); // 条件によって監視.
    log("テキストの描画. アルファベット: $alphabet");
    return Text('alphabet: $alphabet');
  }
}

class Counter with ChangeNotifier {
  int _counter = 0;
  String _alphabet = "a";

  int get getCounter => _counter;
  String get getAlphabet => _alphabet;

  void increment() {
    _counter++;

    // カウンターの値が3の倍数の時にアルファベットを追加する.
    if (_counter % 3 == 0) _alphabet += "a";

    log("+ボタンを押す...");
    // 状態が変化した時にリスナーに通知する
    // (ChangeNotifierクラスのメソッド)
    notifyListeners();
  }
}

ログを見てみます。

[log] テキストの描画. カウンターの値: 0
[log] テキストの描画. アルファベット: a
[log] +ボタンの描画.
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 1
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 2
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 3
[log] テキストの描画. アルファベット: aa
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 4
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 5
[log] +ボタンを押す...
[log] テキストの描画. カウンターの値: 6
[log] テキストの描画. アルファベット: aaa

context.watch()を利用しているカウンター表示のWidgetは値が変わる度に再描画され、context.select()を利用しているアルファベット表示はのWidgetはカウンターが3の倍数になった時だけ再描画がかかっているのが分かります。

MVC(Model-View-Controller)パターン

Providerパッケージを利用することで、MVCパターンで設計することができます。

MVCパターンとは、次のような役割分担を持つ設計パターンのことを指します。

  • Model:データやビジネスロジックを管理する
  • View:画面表示を制御する
  • Controller:ユーザ操作を処理して、ModelとViewの間でやりとりをする

これら3つに役割分担により、コードの保守性と再利用性が大幅にアップします。

Providerパッケージを使う場合、自分で1からアーキテクチャーを構築する必要がありません。

  • Model:ChangeNotifierを継承したクラス
  • View:Widgetクラス
  • Controller:Providerクラス

がそれぞれの役割を担ってくれ、シンプルで可読性の高い設計ができます。

ABOUT ME
なっとう
なっとう
Fluttter開発 プログラマー
噛み砕いて説明できるようになれば、プログラマーとしての質が上がるのではないかと思い、ブログを始めました。 このノウハウが誰かのお役に立てば嬉しいです。
記事URLをコピーしました