【Flutter】Statefulなカウンターアプリをhooks_riverpod+state_notifier+freezedでリファクタリングする

FlutterのNullSafetyがstableになってしばらくたち、最近になってriverpodがstableになったので、記念にhooks_riverpod + state_notifier + freezedの使い方を最低限記載しておきます。
プロジェクトを作った時に作られるカウンターアプリを上記パッケージを使って実現するようにリファクタリングしていきます。

# tl;dr

- flutterの初期アプリをhooks_riverpod+state_notifier+freezedを使うようにリファクタリングする
- 結論としては単機能なアプリでこんなに複雑にやると余計わかりずらい(趣旨としてはサンプルなので良いのですが、、、)
- freezedも無理やり使ったしhooksはさすがに使えなかったので、hooks特有の機能については本記事のスコープ外

#### 完成版の全コード

https://github.com/tokku5552/flutter_riverpod_sample/tree/1.0.0

## 環境
- Android Studio

```yaml:
Android Studio Arctic Fox | 2020.3.1 Patch 3
Build #AI-203.7717.56.2031.7784292, built on October 1, 2021
Runtime version: 11.0.10+0-b96-7249189 amd64
VM: OpenJDK 64-Bit Server VM by Oracle Corporation
Windows 10 10.0
GC: G1 Young Generation, G1 Old Generation
Memory: 8192M
Cores: 8
Registry: external.system.auto.import.disabled=true, debugger.watches.in.variables=false
Non-Bundled Plugins: co.anbora.labs.firebase-syntax-highlighting, com.intellij.marketplace, Dart,
com.thoughtworks.gauge, org.jetbrains.kotlin, io.flutter, org.intellij.plugins.markdown
```

- Flutter SDK

```shell:
> flutter --version
Flutter 2.5.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 4cc385b4b8 (9 weeks ago) • 2021-09-07 23:01:49 -0700
Engine • revision f0826da7ef
Tools • Dart 2.14.0
```

※パッケージのバージョンは本文中に出てきます。


# 初期状態
Flutterのプロジェクトを新規作成してコメントを削除した状態です。

```dart:main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

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

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

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

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  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',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

```

リファクタリングしやすいように以下のようにファイルを分けます。

- main() -> main.dartのまま
- MyApp -> my_app.dart
- MyHomePage(と_MyHomePageState) -> my_home_page.dart

[ファイルを分けるところまでのコミット](https://github.com/tokku5552/flutter_riverpod_sample/commit/69cdba586958500d010e8cd48266631b02a546ba)

※testコードがimportエラーになるので、一旦コメントアウトしています

少しだけ見通しが良くなりました。
初期状態で作られるmain関数とMyAppは置いておいて、`MyHomePage`をリファクタリングしていきます。

## 各パッケージのインストール

- hooks_riverpodは下記の***Installing the package***を参照してインストールします。

https://riverpod.dev/docs/getting_started#installing-the-package

- StateNotifierはこちらから最新のものを

https://pub.dev/packages/state_notifier/install

- freezedについてはfreezedとfreezed_annotation、build_runnerも同時にインストールします。

https://pub.dev/packages/freezed#install

```yaml:pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.18.0
  hooks_riverpod: ^1.0.0
  state_notifier: ^0.7.1
  freezed_annotation: ^0.15.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.1.4
  freezed: ^0.15.0+1
```

- freezedのREADMEの[How to Use](https://pub.dev/packages/freezed#how-to-use)に書いてある通り、`analysis_options.yaml`に以下を追加します。

```yaml:analysis_options.yaml
analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"
  errors:
    invalid_annotation_target: ignore
```

[ここまでのコミット](https://github.com/tokku5552/flutter_riverpod_sample/commit/9e5b33966ce4ee478be767d158f227fdba0de680)

## FreezedでCountを作る
あまり意味がないですが、FreezedとStateNotifierの状態管理になれるために、まずはFreezedでCountを作っていきます。

freezedでは以下のようにクラスを作成することで、immutable(不変)なクラスを自動で生成してくれます。

```dart:count.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'count.freezed.dart';

@freezed
class Count with _$Count {
  @Assert('value >= 0')
  const factory Count([
    @Default(0) int value,
  ]) = _Count;
}
```

書き方としては、

- part '<もとになるファイル名>.freezed.dart' をimport文の下に書く
- `@freezed`アノテーションをつけて_$<クラス名>をmixinする
- コンストラクタなどを定義する
- `freezed_annotation.dart`をインポートする(foundation.dartは必須ではなく、importしておくとdevtoolsが綺麗に認識してくれるようです)

といった感じです。
上記のようにもとになるクラスを作成したら、以下のコマンドを使って`count.freezed.dart`クラスを生成します。

```shell:実行するコマンド
flutter pub run build_runner build --delete-conflicting-outputs
```

`count.dart`は値オブジェクトに近い形で無理やりfreezed使っているので、あまり機能を使えていないですが、
例えばtoJson,fromJsonなどのJSONパーサーも自動生成してくれます(別途json_serializableのインストールが必要)
詳細は[pub.devのfreezedのページ](https://pub.dev/packages/freezed#how-to-use)をご覧ください。
また、あまり意味はないですが、`@Assert`で0以上の整数しか受け付けないようにアサーションしています。
負の値を入れようとするとIDEでエラーを表示してくれます。
![assert.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/83f2f15f-0593-3051-62ab-a0e2b054d7a6.png)

## StateNotifierでカウンターの状態を保持する
Freezedで作ったCountというオブジェクトに入れた数値をStateNotifierを用いて次のようにProviderを作成します。これはMVVMでいうところのViewModelのような役割を持っています。

```dart:counter_notifier.dart
import 'package:flutter_riverpod_sample/count.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final counterProvider = StateNotifierProvider<CounterNotifier, Count>(
  (ref) => CounterNotifier(const Count(0)),
);

class CounterNotifier extends StateNotifier<Count> {
  CounterNotifier(Count state) : super(state);

  void increment() {
    state = state.copyWith(value: state.value + 1);
  }
}

```

- 今回はhooksを使いたいので、riverpod系は全てhooks_riverpodをインポートします
- counterProviderの部分
    - counterProviderとして、外で使えるように宣言します
    - CounterNotifierをインスタンス化します
- CounterNotifierの部分
    - StateNotifier\<T\>を継承してクラスを作成します
    - `T`の部分が操作や監視を行いたい状態です(通常～～Stateと命名した方が分かりやすい)
    - 内部でUIから実行したいメソッドを定義します

## Riverpodを使えるようにする

hooks_riverpod+state_notifier+freezedで状態管理するのに必要なクラス群はそろったので、後はriverpodを使うために、`runApp`メソッドを`ProvideScope`でラップしてあげる必要があります。

```diff_dart:main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod_sample/my_app.dart';

void main() {
-  runApp(const MyApp());
+  runApp(
+    const ProviderScope(
+      child: MyApp(),
+    ),
+  );
}
```

## MyHomePageをリファクタリングする
まずはStatefulWidget特有の部分を除去します。

```diff_dart:my_home_page.dart
import 'package:flutter/material.dart';

-class MyHomePage extends StatefulWidget {
+class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

-  @override
-  State<MyHomePage> createState() => _MyHomePageState();
-}
-
-class _MyHomePageState extends State<MyHomePage> {
-  int _counter = 0;
-
-  void _incrementCounter() {
-    setState(() {
-      _counter++;
-    });
-  }
+ final int _conter = 0;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
-        title: Text(widget.title),
+        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
-        onPressed: _incrementCounter,
+        onPressed: () {},
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

```

そしてHookConsumerWidgetを継承するように書き換えます。

```diff_dart:my_home_page.dart
-class MyHomePage extends StatelessWidget {
+class MyHomePage extends HookConsumerWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;
  final int _counter = 0;

  @override
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, WidgetRef ref) {
    ...
```

このようにすると先ほど作ったProviderが呼べるようになります。
以下のようにして、counterを呼び出します。

```diff_dart:my_home_page.dart
  @override
  Widget build(BuildContext context, WidgetRef ref) {
+    final counter = ref.watch(counterProvider);
    return Scaffold(
 ...
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
-              '$_counter',
+              '${counter.value}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
-        onPressed: () {},
+        onPressed: () {
+          ref.read(counterProvider.notifier).increment();
+        },
 ...
```

- `final counter = ref.watch(counterProvider)`で`counterProvider`が持っている状態(Count)を監視して、変更があれば自動的に画面を再描画する
- `'${counter.value}'`は今回`Count`というオブジェクトの中に値を閉じ込めてしまっているので、わざわざvalueで呼び出し
- FABのonPressedでは`ref.read(counterProvider.notifier).increment()`のように、Notifierで定義したメソッドを呼び出す

基本的に最低限は上記のように、**ref.watch**で値を読み、**ref.read**でメソッドを呼び出すと決めて使っていけばわかりやすいかなと思います。

https://github.com/tokku5552/flutter_riverpod_sample/tree/1.0.0


# まとめ
デフォルトで作成されるカウンターアプリをhooks_riverpod + state_notifier + freezedで無理やりリファクタリングしてみました。
言うまでもなく、カウンターアプリのような単機能のアプリでは**ほぼ無意味に等しい**です。(かえってややこしい)

> **ref.watch**で値を読み、**ref.read**でメソッドを呼び出すと決めて使っていけばわかりやすい

と上では書きましたが、そのように使うのであればhooks_riverpodでなく通常のriverpodを使えば良いです。
[riverpodの公式のサンプル](https://github.com/rrousselGit/river_pod/tree/master/examples/counter)では、もっとスマートにカウンターアプリが記述されています。
flutter_hooksと一緒に使えるところが本来のいいところなので、そちらが実感できるような例も今後お見せできればと思います。
flutter_hooksで使える機能(useXXXX)は[こちらの公式ページ](https://pub.dev/packages/flutter_hooks#existing-hooks)に記載がありますので、一読しておくと良いかと思います。

### 参考

- [https://riverpod.dev/](https://riverpod.dev/)
- [https://pub.dev/packages/hooks_riverpod](https://pub.dev/packages/hooks_riverpod)
- [https://pub.dev/packages/state_notifier](https://pub.dev/packages/state_notifier)
- [https://pub.dev/packages/freezed](https://pub.dev/packages/freezed)
- [https://pub.dev/packages/build_runner](https://pub.dev/packages/build_runner)
- [https://pub.dev/packages/flutter_hooks](https://pub.dev/packages/flutter_hooks)
- [https://github.com/rrousselGit/river_pod/tree/master/examples/counter](https://github.com/rrousselGit/river_pod/tree/master/examples/counter)