🌱

【Flutter】Signals x Riverpod x MVVM という新しい設計手法を考えてみた

2024/09/03に公開

Hooks vs Signals ではない

最近は「Flutter Hooks かSignalsか」という話をちらほら見聞きしますが、実際に触ってみた限りでは違うように思います。
というのも Flutter Hooks とSignalsは役割が異なるためです。

Flutter Hooks
Widgetを書く際の重複を減らし、コードを短くするためのパッケージ

Signals
Riverpodの書き方をシンプルにし、画面更新を減らす機能を充実させたような状態管理パッケージ
ただしRiverpodのようにアプリ全体の状態管理をするのは不向き

大まかに書くとこのような感じです。全く違いますよね。

共存も可能で Riverpod x Flutter Hooks x Signals も出来ます。

useStateの代わりにsignalを使う

Flutter Hooks は便利なパッケージですが、buildメソッド内で状態管理をする仕組み上、Widgetを分割したり、ロジックを別クラスに移したりするのには苦労します。

useStateで管理していた値をRiverpodのNotifierProviderに移すのが一般的だと思いますが、変数一つ移すのに結構なコード量になりますし、もっと手軽さが欲しいところです。

Signalsの良いところはuseStateと同じように書ける上に、Widgetの内でも外でも状態管理が出来るところなので、useStateの代わりとしてsignalを使用することで解決します。

また、effectというuseEffectと似たようなメソッドがあるのですが、これが面白い仕様となっています。

Flutter Hooks の場合
// buildメソッド内で定義する
final count1 = useState(0);
final count2 = useState(0);

useEffect(() {
  print("${count1.value}, ${count2.value}");
  return null;
}, [count1.value, count2.value]); // 変更通知を受けたいStateを列挙する

// count1、count2のいずれかの値が変わると上記のprintが実行される
count1.value += 1;
Signalsの場合
// 基本的にStatefulWidgetか、ViewModel内の変数として定義する
late final count1 = signal(0);
late final count2 = signal(0);

// buildメソッド内ではcreateEffect、それ以外ではeffectを使う
createEffect(() {
  print("$count1, $count2");
});

// count1、count2のいずれかの値が変わると上記のprintが実行される
count1.value += 1;

なんと、useEffectでは必要だった変更通知を受けたいStateの列挙が不要になっています。
effect(createEffect) は最初に一度だけ変更が無くても実行されるのですが、その時に呼ばれたsignalを検出する仕組みになっています。楽で良いですね。

今回のサンプルでは Flutter Hooks を使っていませんが、useStateの代わりとしてSignalsを使い、useAnimationuseTextEditingControllerなどのコードを短くするためのものは Flutter Hooks をそのまま使うのが丁度良い塩梅かなと思います。

カウンターアプリを作る

いよいよ本題のMVVMを作っていきます。
https://github.com/hyshu/flutter_signals_riverpod_mvvm_examples/tree/main/simple_counter

私はInheritedWidgetかProviderパッケージを使ってViewModelを受け渡したい派ですが、多分面倒だと言う方が多そうな気がします笑

ということで、代わりにNotifierProviderでViewModelを管理する方法を考えてみます。


class CounterNotifier extends _$CounterNotifier {
  
  CounterViewModel build() => CounterViewModel();
}

class CounterViewModel {
  final counter = signal(0);

  CounterViewModel() {
    effect(() {
      print("$counter");
    });
  }
}

CounterViewModelというViewModelクラスを作り、CounterNotifierはそれを保持するだけのNotifierProviderとなっています。

buildメソッドは以下のような形。


Widget build(BuildContext context, WidgetRef ref) {
  final vm = ref.watch(counterNotifierProvider);

  return Scaffold(
    body: Center(
      child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        Watch((context) => Text('counter: ${vm.counter}')),
        ElevatedButton(onPressed: () => vm.counter.value += 1, child: const Text("+1")),
      ]),
    ),
  );
}

まず、vmという変数名でCounterViewModelをRiverpod経由で取得します。

final vm = ref.watch(counterNotifierProvider);

基本的にViewModelとページは一対一になるのでvmという名前にしていますが、二つ以上のViewModelを取得することがある場合はcounterVmなどの方が良いと思います。

次にsignalを画面上にTextで表示します。
signalをWidget内で使用する時はWatchを使用します。

Watch((context) => Text('counter: ${vm.counter}'))

最後にタップするたびにcounterが加算されるボタンを作ります。

ElevatedButton(onPressed: () => vm.counter.value += 1, child: const Text("+1"))

これでボタンを押すたびに値が1ずつ増え、ついでにprintされるカウンターアプリの完成です。

シンプルなカウンターアプリのスクリーンショット

もっとカウンターアプリを作る

先ほどのカウンターアプリだとシンプルすぎるので、カウンターを三つにしてみます。
https://github.com/hyshu/flutter_signals_riverpod_mvvm_examples/tree/main/three_counter

class CounterViewModel {
  final counter1 = signal(0);
  final counter2 = signal(0);
  final total = signal(0);

  CounterViewModel() {
    effect(() {
      print("$counter1, $counter2");
      // effect内で += や -= を使う時はuntracked内で行う
      untracked(() => total.value += 1);
      // total.value = total.peek() + 1; でもOK
    });
  }
}

printcounter1counter2を出力することで変更を監視し、変更がある度にtotalの値を増やしています。
ちなみにprintを使わずにcounter1.value;のように書くだけでもOKです。

Widgetはこんな感じです。
一見ごちゃごちゃしていますが、先ほどのカウンターアプリをcounter1counter2に増やしただけです。
最後にtotalをボタン抜きのTextだけで追加しています。

return Scaffold(
  body: Center(
    child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
      Watch((context) => Text('counter1: ${vm.counter1}')),
      ElevatedButton(onPressed: () => vm.counter1.value += 1, child: const Text("+1")),
      const SizedBox(height: 30),
      Watch((context) => Text('counter2: ${vm.counter2}')),
      ElevatedButton(onPressed: () => vm.counter2.value += 1, child: const Text("+1")),
      const SizedBox(height: 30),
      Watch((context) => Text('total: ${vm.total}')),
    ]),
  ),
);

3つのカウンターがあるアプリのスクリーンショット

さて、これでbuildメソッド内の状態管理とロジックをViewModelに移せるようになりました。
以前と変わらずuseStateも使えるので、自由に選択できるのが良いですね。

Widgetの分割も安易になりました。

final vm = ref.watch(counterNotifierProvider);

を呼べば、同じページ内の別WidgetでもCounterViewModelが呼び出せます。

Widget内が複雑になってきたらsetStateの代わりにsignalを使うとコードが分散できて良いね…、と思っていただけたら幸いですが、一箇所良くないところがあるので最後に修正します。

同じページが二つあっても正しく動作させる

final vm = ref.watch(counterNotifierProvider);

これは同じページ内で共通のCounterViewModelが取得できて便利なのですが、画面遷移後のページでも同じCounterViewModelが共有されてしまうという問題があります。

画面遷移後もカウンターが引き継がれてしまっているスクリーンショット

同じページに遷移することが無いアプリや、ページ遷移後もカウンターの値を引き継ぎたいのであれば問題ありませんが、そうでない場合は遷移後はちゃんと0から始まるようにしたいですね。

対策は簡単で、CounterNotifierに引数を追加するだけです。

class CounterNotifier extends _$CounterNotifier {
  
- CounterViewModel build() => CounterViewModel();
+ CounterViewModel build(ModalRoute route) => CounterViewModel();
}

そして、CounterViewModelを取得する際にModalRoute.of(context)を引数に渡します。

final vm = ref.watch(counterNotifierProvider(ModalRoute.of(context)!);

画面遷移すると新しいModalRouteが作られる仕様なので、同じページ内ならModalRoute.of(context)で取得できるModalRouteが同じであることを利用しています。

これでページごとに別個のCounterViewModelが取得できるようになりました。

画面遷移後はカウンターが別個に管理されているスクリーンショット

おわりに

もっと設計を深めたい方はヘルパー編もご覧ください。
https://zenn.dev/zoome/articles/629a2489c0a4be

Signalsの基本的な概要についても以前記事を書いたので、こちらもお読みいただけると嬉しいです。
https://zenn.dev/zoome/articles/934a23c014c52b

備考

本記事は以下の環境で検証しました

flutter_hooks: 0.20.5
flutter_riverpod: 2.5.1
riverpod_annotation: 2.3.5
riverpod_generator: 2.4.3
signals: 5.5.0

Flutter 3.24.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5874a72aa4 (13 days ago) • 2024-08-20 16:46:00 -0500
Engine • revision c9b9d5780d
Tools • Dart 3.5.1 • DevTools 2.37.2
GitHubで編集を提案
合同会社zoome(ズーム)

Discussion