SwiftUI 風味の状態管理ライブラリを作った
このたび、状態管理ライブラリ ushio を作りました。 SwiftUI の状態管理の仕組みを見様見真似で Flutter に持ち込んだライブラリです。
この記事では特徴と主な使い方を説明します。本ライブラリを使うことがなかったとしても、 Flutter の状態管理の参考になれば幸いです。なにしろ駆け足で作ったので至らぬ点だらけですが何卒ご容赦ください。
動機
状態管理ライブラリの学習が面倒くさい
私が業務で作るのは主に小規模な社内用のアプリで、個人的に作るアプリでも複雑な画面遷移はありません。状態管理はだいたい setState()
で済みます。 setState()
のみだとつらくなったときに状態管理ライブラリを使いたいのですが、 Riverpod を使うほどでもありません。でも Provider ではあまり楽になりそうに見えない。
というか、状態管理で悩んだ経験が少ないせいか状態管理ライブラリの説明をいくら眺めても頭に入ってこない。そこで、 Flutter の学習も兼ねて、そこそこ慣れている SwiftUI の状態管理を Flutter でもやってみようと思いました。
本ライブラリで Riverpod を超えたいとか、覇権を獲りたいといった野望はありません。気軽に使える状態管理ライブラリとして、選択肢の一つになれたらいいと思います。
SwiftUI との違い
本ライブラリは SwiftUI を完全に再現するものでありません。 Flutter の性質や慣習に合わせて仕様を変えています。
- アノテーションは (今は) 用意していません。プロパティのラッパーはメソッド呼び出しで生成します。
- ラッパーは隠蔽されません。プロパティ値へのアクセスは明示的にラッパーを通して行います。
-
@ObservedObject
相当の機能は未実装です。 Flutter と SwiftUI では UI のライフサイクルが異なるため、@StateObject
と区別する必要性が低いと判断しました。 - コレクションの監視はリストとマップのみです。多次元のデータ構造はサポートしていません。
特徴
プロパティを監視し、値に変更があればウィジェットを自動的にリビルド (再描画) します。基本はこれだけです。 GetX の状態管理に少し似ているかもしれません。
基本的な使い方
例として、プロジェクト生成時のテンプレートであるカウンターアプリを挙げます。中心となる State の実装は次のようになります。
class MyHomePageState extends ManagedState<MyHomePage> {
// プロパティをラップして監視する
// ラップ後の型は Binding<int> となる
// カウンターの初期値は 0
late final _counter = state(0);
void _incrementCounter() {
// プロパティ値を変更する
// 自動的に画面がリビルドされる (setState が呼ばれる)
_counter.value++;
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
// ラッパー経由でプロパティ値を参照する
'${_counter.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
元のコードからの変更点は次の通りです。
-
State
の代わりにManagedState
を継承する - カウント数のプロパティ
_counter
をstate()
でラップする - プロパティ値へのアクセスは
_counter.value
で行う -
_incrementCounter()
でsetState()
を呼ばない
ManagedState
クラスを定義する
本ライブラリの状態管理は StatefulWidget
と State
を継承した ManagedState
を利用します。状態管理を行いたい StatefulWidget
の状態クラスで ManagedState
を継承し、ウィジェットの createState()
で生成します。 StatefulWidget
側でその他に必要な処理は特にありません。
例: 状態クラスを定義する:
class MyHomePage extends StatefulWidget {
MyHomePage({super.key});
State<StatefulWidget> createState() => MyHomePageState();
}
// State -> ManagedState
class MyHomePageState extends ManagedState<MyHomePage> {
...
}
監視するプロパティを定義する
次に、監視するプロパティを ManagedState
のサブクラスに定義し、プロパティのラッパーを生成します。プロパティの定義では late
と final
を使うと型名を省略できて簡単です。
例: プロパティを定義する:
// int 型をラップする
late final _counter = state(0);
プロパティのラッパーは複数あります。 state()
は ManagedState
で使えるラッパーの一つです。プロパティ値は value
でアクセスできます。プロパティ値を変更すると setState()
が呼ばれ、ウィジェットがリビルドされます。
例: プロパティ値を変更するとウィジェットがリビルドされる:
void _incrementCounter() {
_counter.value++;
}
その他のラッパーは後述します。
プロパティの監視方法
監視するプロパティの型と用途によって使用するラッパーが異なります。現在は以下のラッパーを用意しています。
ManagedState.state()
ManagedState.stateObject()
ManagedState.stateList()
ManagedState.stateMap()
ManagedState.environmentObject()
ObservableObject.published()
ObservableObject.publishedList()
ObservableObject.publishedMap()
基本的な型のプロパティを監視する
ManagedState
では、次の型のプロパティを state()
ラッパー (List
は stateList()
, Map
は stateMap()
) で監視できます。
-
int
,double
,num
string
bool
-
List
(stateList()
) -
Map
(stateMap()
)
List
と Map
は要素の数に変更があるとウィジェットをリビルドします。また、要素の型が ObservableObject
であれば、要素の内容が変更されてもリビルドします。多次元のデータ構造はサポートしませんので、要素の型がコレクションを含む場合は他の方法でリビルドのタイミングを管理してください。
例: state()
でプロパティを定義する:
// 引数はプロパティの初期値
late final _counter = state(0);
オブジェクトのプロパティを監視する
ManagedState
では、基本的な型以外のオブジェクトのプロパティも監視できます。監視できるオブジェクトは ObservableObject
のサブクラスである必要があります。
ObservableObject
のプロパティで監視できる型は ManagedState
と同じです。ただし、 ObservableObject
では published()
ラッパー (List
は publishedList()
, Map
は publishedMap()
) を使います。
例: 監視するオブジェクトのクラスを定義する:
class Counter extends ObservableObject {
// 引数はプロパティの初期値
late final _count = published(0);
}
ObservableObject
のオブジェクト自身の監視は、 ManagedState
で stateObject()
ラッパーを使って行えます。次のコードは Counter
を監視するプロパティの定義と値の変更の例です。メソッドチェーンが長くなるのが難点です。
class MyHomePageState extends ManagedState<MyHomePage> {
// 引数はプロパティの初期値
late final _counter = stateObject(Counter());
void _incrementCounter() {
// stateObject と published を通じてプロパティにアクセスする
_counter.value._count.value++;
}
}
ウィジェット間でオブジェクトを共有する
これまでに挙げたラッパーは、いずれも一つのウィジェット (ManagedState
) 内で完結するものです。 environmentObject()
を使うと、監視するオブジェクトを複数のウィジェット間 (親子関係のあるツリー) で共有できます。監視できるオブジェクトは、こちらも ObservableObject
です。
environmentObject()
を使うには、オブジェクトを共有したいウィジェットを Environment
ウィジェットで囲みます。 Environment
の引数に、監視するオブジェクトのリストと子ウィジェットを渡します。
例:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Environment(
values: [Counter()],
child: const MyHomePage(title: 'Multi Counter'),
),
);
}
}
Environment
に渡したオブジェクトは、 environmentObject()
に型を指定したプロパティでアクセスできるようになります。
例: environmentObject()
でプロパティを定義する:
class _MyHomePageState extends ManagedState<MyHomePage> {
late final _envCounter = environmentObject<Counter>();
}
ウィジェットツリーで共有できるオブジェクトは、型につき一つです。たとえば上記の例では、共有できる Counter
オブジェクトは一つです。複数の Counter
オブジェクトは共有できません。
environmentObject()
でラップしたオブジェクトにアクセスするには BuildContext
が必要になります。次のコードは Counter
オブジェクトの published()
で定義した count
プロパティにアクセスする例です。
final text = 'environmentObject() ${_counter.value(context).count.value}'
今後の予定
現在はプロパティのラッパーをメソッド呼び出しで生成していますが、これを SwiftUI のようにアノテーションを使って定義することもできそうです。プロパティ値へのアクセッサーを自動的に生成するなどして、メソッドチェーンを短縮できると思います。
おわりに
状態管理は Riverpod を始めとして優秀なライブラリが揃っている今、たいして知見のない私がぽっと出のライブラリを作っても実用性は微々たるものだと思います。コードを書いているとときどき正気に戻って「自分がこれを作って何か意味あるのか?」と自問することもしばしば。
個人的に Flutter の魅力はデスクトップもサポートしたクロスプラットフォームな開発環境でもありながら、民主的であることだと思います。 Unity Technologies の創立者で元 CEO のデイビッド・ヘルガソンさんが言うところの「非常にパワフルで、誰でも使うことができ、洗練されて使いやすく、いかなるプラットフォーム向けの開発もできるもの」 が Flutter にも当てはまるのではないでしょうか。アプリ開発の敷居が大幅に下がり、プログラミングに詳しい人しかできなかったアプリ開発の道が多くの人に開かれる (た) …と、プログラミング初心者の方が Flutter で自分なりのアプリを作った (る) 記事やツイートの多さを見ていて感じます。
だから、 100 人いれば 100 通りのやり方があっていいのです。もちろん業務でアプリ開発をするなら好き勝手にやるわけにもいきませんが、ベストプラクティスなんてクソ喰らえだと心の中では思っています。よりよいやり方はあっても、誰かの正解を正解にされるのはまっぴらごめんです。だから個人的には、いくらでもライブラリが乱立してくれていいのです。
Discussion