Pocket

宣言的UI(declarative UI)

Flutterの特徴に宣言的UIがあります。コードベースでの表現になりますが、全てをコンストラクタで初期化するスタイルで、ビューをいじるロジックが一切搭載できない書き方です。これが最もわかりやすい説明だと思っています。以下は、Flutter公式サイトに乗っているコードです。

https://flutter.dev/docs/get-started/flutter-for/declarative

// Imperative style
b.setColor(red)
b.clearChildren()
ViewC c3 = new ViewC(...)
b.add(c3)

// Declarative style
return ViewB(
  color: red,
  child: ViewC(...),
)

後者がFlutterで採用されている宣言的UIです。ViewBViewCを子供として持ちますとコンストラクタで宣言して、それをreturnします。全部この書き方です。setColorみたいな関数は一切公開されていません。

前者の書き方だと、orderTextBox.text ="aaa"という書き方でラベルの値を更新します。しかし、Flutterの場合はそのような書き方ができません。`Text(value)`という書き方になります。valueは変数です。valueの値が変化しようとも、valueを表示することは変わらないだろという理屈です。

次に考えなければならないのが、valueの変化をどうやってUIに伝えるかです。宣言的UIの場合、値が変更されたら当該UIを再構築(リビルド)するしか方法がないので、valueをただ再代入で更新するだけではなく、更新したことをUIに伝搬する必要があります。Flutterそれ自体に、WPFのようなデータバインディングの宣言を書くところはありませんので、一工夫必要になります。

Flutterには「StatefulWidget」と「StatelessWidget」があります。StatefulWidgetはsetState関数を呼び出すことでWidgetのbuild関数が呼び出されUIを更新します。Widgetがもつライフサイクルを利用して、UIをリビルドするやり方で、最も原始的な仕組みです。

StatefulWidgetに依存した関数を呼び出すことでWidgetの状態を変えるやり方は、Widgetの再利用性が損なわれます。明示的に関数を呼び出すという仕組みは、簡単に見えて統制が難しい。setStateを呼び出すとStateを継承しているWidgetに対して実行されるので、ラベルだけを更新したにもかかわらずListViewも全部再構築されたりします。しかも各々別個に非同期に実行されます。画面がスクリーンから消えた場合の廃棄処理も書かないといけません。統制を取るのがだんだん難しくなります。

ウイジェットのライフサイクルに沿って状態管理をする方法は、面倒なことが多い。ウイジェット自体は別にStateを持たなくて良く、データの更新に合わせてUIが更新できれば良い。いい感じに状態管理ができるライブラリが求められます。

ProviderとRiverpod

Flutterで状態管理を行う場合、現時点で(短いながら)歴史があって公式サイトでも記載があるのが「Provider」パッケージです。

https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple

これを利用するとsetStateを利用せずにデータバインディングのようなやり方でUIを再構築できます。というのも、FlutterにはInheritWidgetというのがあり、UIの変更を下位ツリー内の特定のWidgetのみに伝播する(リビルドを発生させる)ことができる仕組みがあります。この仕組みをより簡便に使えるようになったラッパーが、Providerです。使い方は、こんな感じです。

  • ChangeNotifierをMixinしたクラスを作り、データ操作する関数を作って、内部でnotifyListener()を呼び出す。
  • ChangeNotifierProviderでChangeNotifierを初期化して、Consumerのコールバックで値を受け取ることで、定義したChangeNotifierのプロパティやメソッドが操作でき、UIが更新できます。

これでも生きていけますが、このProvider(状態を保持する仕組み)の種類が増え細分化されて、機能が増えテスタビリティが向上したのがRiverpodです。RiverpodはProviderの作者の方が中心となった、Providerの設計思想を根本から見直して書かれたライブラリです。使い方はこんな感じ。

  • RiverpodでサポートされているProviderを定義する
    • 内部の状態を外部から変更できるものと変更できないものがある。
    • API通信のように非同期処理に適したProviderもある。
  • Consumer,ConsumerWidget,HookWidgetのどれかを使い、Providerを取得
  • メソッドなどを通じて、状態(保持してるデータ)を更新する。

RiverpodでサポートされているProviderでよく利用されるのが、StateNotifierProviderです。ChangeNotifierを更にシンプルにしたもので、以下の特徴があります。

  • T型のstateという変数しか持てない。stateを再更新することでUIの更新が可能になる。
  • notifyListener()を呼び出す必要がない。

T型のstateしか持てなくなることで、状態の更新に工夫が必要です。

たとえば、名前とカナと検索結果のリストの3つしかない画面があるとします。保持するデータはこんなもんでしょう。これを持つとこうなります。

//管理したいデータを定義する
class UserSearch {
 String name = "";
 String kana = "";
 List<User> items = [];
}

//このクラスはUserSearch型のstateという変数を持つ
class UserSearchStateNotifier extends StateNotifier<UserSearch> {
  UserSearchStateNotifier() : super(const UserSearch());

 void setName(String name) async {
   //こう書くとバグを生み出す
    final f = UserSearch(name: name);
    state = f;
  }
}

UIを更新する手段が1つしかないのが特徴で、stateに新しいインスタンスを作って再代入することで実現できます。注意が必要なのは、インスタンスの再代入が必要なこと。nameだけを変更してもプロパティが変更されただけで、インスタンスが変更されたわけではありません。

intとかStringみたいなプリミティブ(これJava用語だった気がする)な型であれば、1つの値しか持てませんので、再代入すればインスタンスが更新されます。オブジェクトの場合はそうはいかないという話。

nameやkanaなど、既存のインスタンスで保持されているプロパティを引き継ぎながら、変更があったプロパティだけを更新して、新しいインスタンスを作る。これを手作業でちまちま再代入するのは非常に退屈でつまらないコードです。プロパティの統廃合があったら、書き直し。ダメダメです。

これを解決するために使われるのがFreezedパッケージです。イミュータブルな状態を扱いやすくするコードジェレレーションをするライブラリで、こちらを使うと以下のcopyWith関数が生成され、既存のデータを保持しながらイミュータブルなインスタンスを返してくれます。

part 'user_search.freezed.dart';

@freezed
class UserSearch with _$UserSearch {
  const factory UserSearch({
    @Default("") String name,
    @Default("") String kana,
    @Default([]) List<User> items
  }) = _UserSearch;
}

void setName(String name) async {
   //これだけで生きていけるようになる
   state = state.copyWith(name: name);
}

これを使えば、stateの再代入で新しいインスタンスをセットしてくれます。ここまで単純化できるのも、すごいことだな〜と思います。

取り急ぎ、ProviderとRiverpodの思想的な部分をまとめてみました。ではでは。