🌷

【Flutter】Signals x Riverpod x MVVM という新しい設計手法を考えてみた (ヘルパー編)

2024/09/03に公開

前回作ったSignalsとRiverpodによるMVVMをもう少し改良します。
https://zenn.dev/zoome/articles/268adc7ca2f594

ヘルパーとは

前編では Flutter Hooks のsetStateの代わりにSignalsのsignalを使うと、Widgetを分割しやすくなったりロジックをViewModelに移せるようになったりして便利という話をしました。

ただ、設計にこだわりのある方なら、ボタンをタップした時に実行する

vm.counter.value += 1

が微妙だな…と感じるかと思います。

これはRiverpod3.0からStateProviderが非推奨になり、代わりにNotifierProviderを使用するようになった理由と関連してくるのですが、
値の更新方法を限定するとコードが管理しやすくなり、不具合も起きにくくなる」という設計のテクニックがあります。

例えば、今後アプリに機能が増えて、色々なところで+=1という処理を書くかもしれません。
カウンターの個数を増やそう…とか、スマホのカメラに手をかざすだけでも+=1する機能を付けよう…などなど。

その後、
1ずつしか増やせないのは止めて、ユーザーが自由に増やす値を設定できるようにしよう」となった際に、その+=1と書いた箇所を全部修正する必要があります。

面倒ですし、一箇所でも修正漏れがあれば不具合になってしまいますね。

なので、+=1と書く代わりにincrementというメソッドを作り、それでしか更新できないようにすれば、そのincrement内の処理を変更するだけで対応完了になります。

このように、同じ処理を二回以上書く場合に、処理部分を一つのメソッドとしてまとめる手法をヘルパー(Helper)と言い、メソッド自体はヘルパー関数と呼びます。

ヘルパー関数を追加する

では、前編のシンプルな方のカウンターアプリを改良していきます。

https://github.com/hyshu/flutter_signals_riverpod_mvvm_examples/tree/main/simple_counter_with_helper

signalにはヘルパーを考慮してかreadonly()というメソッドが用意されています。
これを最初のカウンターアプリに使用してみます。

class CounterViewModel {
-  final counter = signal(0);
+  final counter = signal(0).readonly();

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

これで、counterSignal型からReadonlySignal型になり、buildメソッド内でcounter.value += 1と書けなくなりました。

次に、NotifierProvider側にincrementメソッドを追加します。

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

+ void increment() => (state.counter as Signal).value += 1;
}

実はReadonlySignal型はSignal型の親クラスなので、こうしてSignal型に戻してやるだけで再び更新できます。
とはいえ毎回as Signalと書くのも面倒なのでextensionを作っておきます。

extension Writable<T> on ReadonlySignal<T> {
  T get writable => value;
  set writable(T value) => (this as Signal<T>).value = value;
}

void increment() => state.counter.writable += 1;

さて、これでvalueからは直接更新が出来ないようになりました。
buildメソッド内で更新する方法はRiverpodのNotifierProviderを更新する時と同じです。

ref.read(counterNotifierProvider.norifier).increment();

ただ、これだとちょっと長いですよね。それにvm経由で更新できた方が分かりやすいです。
ということでCounterViewModelnorifierを追加します。

class CounterNotifier extends _$CounterNotifier {
  
- CounterViewModel build() => CounterViewModel();
+ CounterViewModel build() => CounterViewModel(counterNotifierProvider.notifier);

  void increment() => state.counter.writable.value += 1;
}

class CounterViewModel {
  final counter = signal(0);

+ final Refreshable<CounterNotifier> notifier;

- CounterViewModel() {
+ CounterViewModel(this.notifier) {
    effect(() {
      print(counter);
    });
  }
}

これでvm.notifierと書けるようになりました。

ref.read(vm.norifier).increment();

最後にbuildメソッド内の処理をincrementに置き換えます。

@override
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")),
+        ElevatedButton(onPressed: ref.read(vm.norifier).increment, child: const Text("+1")),
      ]),
    ),
  );
}

おわりに

Signalsには今回ご紹介しきれないくらい沢山の機能があります。
https://dartsignals.dev/reference/overview/

国内でもSignalsの使い方についての議論が深まっていくと嬉しいです。

備考

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

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