flutter_hooks + riverpodでViewModel風


flutterではUI(Widget)が、状態を持つことができます(StatefulWidget)
そして状態を変更すると自動でUIに反映されます

Androidで言えば、データバインディングをデフォルトサポートしている感じでしょうか
↓Flutterのデモアプリで考えてみます

普通にFlutterで状態管理した場合

以下はFlutterプロジェクトを作成して自動で生成されるサンプルコードです
setState関数を使ってStatefulWidgetが持つ状態を更新すると、自動でbuild関数が実行されてUIも更新されます

main.dart
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() { // <<<<< ここで状態を更新する
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // 状態を更新するとbuildが再実行されてUIが更新される
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

何とかしたいこと

私が作っているアプリはそれほど規模が大きくありませんが、それでもふたつ問題が出てきました

とにかくアプリが重い

変化しうるWidgetを全てStatefulWidgetで書いていたのですが、アプリの動きがカクつきがちでした
というのもFlutterでは入れ子になっているWidgetが更新されると、親のWidgetからまとめて更新されてしまうようです
例えばReactだと差分だけ更新してくれたりするんですが...

画面間での状態共有が難しい

複数の画面で同じ状態を共有しようとするとちょっと難しいです
例えば根っこのWidgetで状態をまとめて管理することが挙げられますが、親子Widget間でコールバックを受け渡すことになりそうです

flutter_hooks + riverpodで解決する

そこでflutter_hooksとriverpodで解決を図りました
正直なところ仕組みを全然把握できていないのでアレですが、非常にシンプルには書けたと思います

バージョンとか

  • Flutter 2.0.6
  • MacOS Big Sur 11.2.3
  • AndroidStudio 4.1.2

準備

まずはパッケージを追加します。それぞれのバージョンは2021/05/03の最新バージョンです

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.16.0. # <<<<< 追加
  hooks_riverpod: ^0.13.1. # <<<<< 追加

実装

ViewModel

今回は整数型のcounterしか状態がないので本当はもっとシンプルに書けると思いますが、拡張性を考えてMainViewModelStateクラスにまとめてみました

main_view_model.dart
import 'package:flutter/cupertino.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final mainViewModelNotifierProvider = StateNotifierProvider( (_) => MainViewModel());

class MainViewModelState {
  int counter;

  MainViewModelState({@required this.counter});
}

final initialState = MainViewModelState(counter: 0);

class MainViewModel extends StateNotifier<MainViewModelState> {
  MainViewModel() : super(initialState);

  void incrementCounter() {
    state = MainViewModelState(counter: state.counter + 1);
  }
}

View (ViewModelを使う方)

書き方はいくつかあると思いますが、私はHookBuilderを使うのがシンプルで好みでした
ViewModelはグローバルに参照できるので、複数の画面でViewModelの更新を受信することができますし、もちろんViewModelの関数を複数画面から実行することもできます

加えて、StatefulWidgetを使っていたところをStatelessWidgetにすることができるので、状態更新に伴うWidgetの更新範囲を絞ることができます
おそらく処理速度的にはいくらかマシなはず

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mvvm_sample/main_view_model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

void main() {
  runApp(
      ProviderScope( // <<<<< 追加
        child: MyApp()
      ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatelessWidget { // <<<<< Statelessに変更
  const MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            counterText(),
          ],
        ),
      ),
      floatingActionButton: incrementButton(), 
    );
  }
}

Widget incrementButton() {
  return HookBuilder(builder: (BuildContext context) {
    final controller = useProvider(mainViewModelNotifierProvider);

    return FloatingActionButton(
      onPressed: () => controller.incrementCounter(),
      tooltip: 'Increment',
      child: Icon(Icons.add),
    );
  });
}

Widget counterText() {
  return HookBuilder(builder: (BuildContext context) {
    // ViewModelの状態が更新されたら再実行される
    final state = useProvider(mainViewModelNotifierProvider.state);

    return Text(
      '${state.counter}',
      style: Theme.of(context).textTheme.headline4,
    );
  });
}

まとめ

ということで、flutter_hooks + riverpodでシンプルにViewModelを書いてみました
これで画面間の状態共有がかなりシンプルに書けています

ただし繰り返しになりますが、flutter_hooksやriverpodの仕組み自体はほとんど把握できていなかったりします。先人たちの見様見真似で何とか実装できた感じです
より良い実装の方法や、仕組みがわかる資料などがありましたら教えていただけると助かります!