🧂

SwiftUI 風味の状態管理ライブラリを作った

2023/02/15に公開

このたび、状態管理ライブラリ 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 を継承する
  • カウント数のプロパティ _counterstate() でラップする
  • プロパティ値へのアクセスは _counter.value で行う
  • _incrementCounter()setState() を呼ばない

ManagedState クラスを定義する

本ライブラリの状態管理は StatefulWidgetState を継承した ManagedState を利用します。状態管理を行いたい StatefulWidget の状態クラスで ManagedState を継承し、ウィジェットの createState() で生成します。 StatefulWidget 側でその他に必要な処理は特にありません。

例: 状態クラスを定義する:

class MyHomePage extends StatefulWidget {
  MyHomePage({super.key});

  
  State<StatefulWidget> createState() => MyHomePageState();
}

// State -> ManagedState
class MyHomePageState extends ManagedState<MyHomePage> {
  ...
}

監視するプロパティを定義する

次に、監視するプロパティを ManagedState のサブクラスに定義し、プロパティのラッパーを生成します。プロパティの定義では latefinal を使うと型名を省略できて簡単です。

例: プロパティを定義する:

// 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() ラッパー (ListstateList(), MapstateMap()) で監視できます。

  • int, double, num
  • string
  • bool
  • List (stateList())
  • Map (stateMap())

ListMap は要素の数に変更があるとウィジェットをリビルドします。また、要素の型が ObservableObject であれば、要素の内容が変更されてもリビルドします。多次元のデータ構造はサポートしませんので、要素の型がコレクションを含む場合は他の方法でリビルドのタイミングを管理してください。

例: state() でプロパティを定義する:

// 引数はプロパティの初期値
late final _counter = state(0);

オブジェクトのプロパティを監視する

ManagedState では、基本的な型以外のオブジェクトのプロパティも監視できます。監視できるオブジェクトは ObservableObject のサブクラスである必要があります。

ObservableObject のプロパティで監視できる型は ManagedState と同じです。ただし、 ObservableObject では published() ラッパー (ListpublishedList(), MappublishedMap()) を使います。

例: 監視するオブジェクトのクラスを定義する:

class Counter extends ObservableObject {
  // 引数はプロパティの初期値
  late final _count = published(0);
}

ObservableObject のオブジェクト自身の監視は、 ManagedStatestateObject() ラッパーを使って行えます。次のコードは 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