🕌

Flutterの状態管理ライブラリの選択

2024/03/03に公開

Flutterの状態管理ライブラリについて

Flutterの状態管理ライブラリについては、色々なところでベストプラクティスが書かれていますが、仕事で使用した状態管理ライブラリの使用感と自分的な位置付けを整理してみます。

StatefulWidget
ConsumerStatefulWidget
ChangeNotifier
HookWidget

また、以下はライブラリではありませんが、ChangeNotifierを利用した、XxxxxRepositoryというシングルトンのクラスを設計上使用していました。

Repository

これらの役割について完結に整理しておきます。

StatefulWidget

用途: 主に同一Widget内で、状態を保持・管理したい時に使います。
ページ(Widget)の見た目や状態が変化する

その変化は、ページ(Widget)内の操作を入力とする (ボタン操作、TextFieldの入力、タイマーによる変化など)

使用方法は

(1) メンバ変数を定義する 例) int _counter=0;
(2) このメンバ変数を参照したUI等を実装する 例) Text('$_counter')
(3) ボタン操作等、状態変化を起こすイベントで、setStateを呼び、そのcallbackの中で状態を更新する

  例) setState(()=>_counter++);
  →setStateが呼ばれると、buildが再度走り、描画が更新されます

StatefulWidgetの使用方法については色々なところで既に記事があるので詳述しません。

ConsumerStatefulWidget

ConsumerStatefulWidgetはflutter_riverpodのWidgetの一つで、StatefulWidgetに似た使用感で利用でき、且つ以下の用途で使用できます。

用途: 主にWidgetを跨いで、状態を保持・管理したい時にChangeNotifier(自分の設計だとRepository)と合わせて使います。

  • ページ(Widget)の見た目や状態が変化する
  • その変化は、ページ(Widget)外部の操作等を入力とする(別のページで発行されたAPIの結果など)
    上記のような場合に使用します。

主要な使用方法は以下です
(1) buildメソッド内でref.watch(SomeRepository.provider);としてRepositoryを取得

  @override
  Widget build(BuildContext context) {
    final userRepository = ref.watch(UserInfoRepository.provider);
    ...
  }

(2) Repositoryの値を使用してUIを構成する

Text('${(userRepository.userInfo.email)}')

(3) UserRepositoryはこんな感じ

class UserInfoRepository extends ChangeNotifier {
  static final _instance = UserInfoRepository._internal();
  factory UserInfoRepository() => _instance;
  UserInfoRepository._internal();
  static final provider =
      ChangeNotifierProvider<UserInfoRepository>((ref) => _instance);

  UserInfo get userInfo => _userInfo;
  UserInfo _userInfo = UserInfo();

  ...

(3) RepositoryからnotifyListenersが実行された時に、ref.watchしている側のbuildメソッドが走り、再描画され、UIが更新される

  Future<UserInfo> getUserInfo() async {
    _userInfo = await getUserInfo();
    notifyListeners(); 
  }

※このコードは_userInfoがMutableでなので不都合ならImmutableにする何かを追加する必要があります。

(4) 監視はせずProviderから状態を一度だけ読み出したい場合はref.watchではなくref.readを使用します。

late UserInfo userInfo;

@override
void initState() {
super.initState();
userInfo = ref.read(UserInfoRepository.provider).userInfo;
}

(5) notifyListenerがあった時に、UIの更新ではなく、特定のメソッドの実行など、処理を実行したい場合はref.listenを使用します

ref.listen(MyEvent.provider, (previous, next) {
    _updatePage();
});

ConsumerStatefulWidgetについても記事がたくさんあるので詳細は省略します

Repositoryについて

用途:

View → Modelで言うところの、Model層に相当します(各WidgetがViewです)

API等、アプリ外部から情報を受け取り、内部的に情報をキャッシュする役割として使用しています

シングルトンであり、アプリケーションのどこからでもアクセス可能です

上記の通り、自分が設計した案件ではConsumerStatefulWidgetに状態の変化を通知するのに使用していました。

ChangeNotifierを継承しており、このクラスでnotifyListenerとすると、このクラスのProviderをwatch, listenしているクラスにイベント通知し、再描画や処理実行をさせることができます。

既に上に記載していますが、こんな感じの実装です。

class UserInfoRepository extends ChangeNotifier {
  static final _instance = UserInfoRepository._internal();
  factory UserInfoRepository() => _instance;
  UserInfoRepository._internal();
  static final provider =
      ChangeNotifierProvider<UserInfoRepository>((ref) => _instance);

  UserInfo get userInfo => _userInfo;
  UserInfo _userInfo = UserInfo();

  ...

HookWidget

flutter_hooksで実装されているHookWidgetです。
できることはConsumerStatefulWidgetと変わらないですが、StatefulWidgetやConsumerStatefulWidgetがWidgetを作成するためにclassを二つ作らないといけないのに対し、HookWidgetはクラスが一つでいい、というメリットがあります。
自分が入った案件では、既にStatefulWidgetで記載されていたので、書き方がほぼ同じComsumerStatefulWidgetを使用し、HookWidgetは使いませんでしたが、これから新規で始める案件はHookWidgetを使用した方が楽ができます。まあ、クラス2つが1つになると言っても、ちょっとの差なので、今CosumerStatefulWidgetで書かれたコードを頑張ってリファクタリングするほどでもないとは思います。大きな差異はないと感じます。

注意点は、HookWidgetで状態管理対象の変数を使用したメソッドを使う場合、buildメソッド内に関数を書かないと面倒くさくなる。build関数外にメソッドを書いてもいいが、状態管理対象の変数を全部引数で渡す必要が出てきてめんどくさい。下の例のemailやpasswordを使うisSubmittableをbuildメソッド外にすると、isSubmittableにemail, passwordを渡さないといけなくなる。これはStatefulWidgetには考える必要のない注意点になります。

メソッドの定義含めて全部がbuildメソッド内に書いてあるというのは最初少し違和感がありますが、慣れれば普通になります。

  @override
  Widget build(BuildContext context) {
    final email = useState('');
    final password = useState('');
    bool isSubmittable() {
      if (email.value.isNotEmpty && password.value.isNotEmpty) {
        return true;
      }
      return false;
    }

    return Scaffold(
      body: Column(
          children: [
            EmailInput(text: email),
            PasswordInput(text: password),
            NextButton(enabled: isSubmittable()),
          ],
        ),
    );
  }

Discussion