【Flutter】Signals x Riverpod x MVVM という新しい設計手法を考えてみた
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
と似たようなメソッドがあるのですが、これが面白い仕様となっています。
// 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;
// 基本的に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を使い、useAnimation
やuseTextEditingController
などのコードを短くするためのものは Flutter Hooks をそのまま使うのが丁度良い塩梅かなと思います。
カウンターアプリを作る
いよいよ本題のMVVMを作っていきます。
私は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
されるカウンターアプリの完成です。
もっとカウンターアプリを作る
先ほどのカウンターアプリだとシンプルすぎるので、カウンターを三つにしてみます。
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
});
}
}
print
でcounter1
とcounter2
を出力することで変更を監視し、変更がある度にtotal
の値を増やしています。
ちなみにprint
を使わずにcounter1.value;
のように書くだけでもOKです。
Widgetはこんな感じです。
一見ごちゃごちゃしていますが、先ほどのカウンターアプリをcounter1
、counter2
に増やしただけです。
最後に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}')),
]),
),
);
さて、これで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
が取得できるようになりました。
おわりに
もっと設計を深めたい方はヘルパー編もご覧ください。
Signalsの基本的な概要についても以前記事を書いたので、こちらもお読みいただけると嬉しいです。
備考
本記事は以下の環境で検証しました
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
Discussion