FlutterのStateNotifierパターンでカウントアップアプリ作ってみた

#あいさつと最近のFlutterの実装パターン
はじめまして。株式会社ゆめみの清水 亮です。

最近(2020年5月)はFlutterの実装パターンに様々な物が出てきました。
今最も注目されているのは恐らくStateNotifierパターンではないでしょうか。

変数一つ一つを監視するChangeNotifierパターンと似たような感じで、
変数群を一纏めにして監視するのがStateNotifierパターンです。

この記事ではStateNotifierパターンを解説しますが、
ChangeNotifierパターンも理解したい人は[この記事](https://ja.unflf.com/tech/flutter/provider/)がおすすめです。
個人的にChangeNotifierパターンを理解した後に本記事を読む方が理解が早いと思います。

#今回作りたいアプリ
今回は以下の様なよくあるカウントアップアプリをStateNotifierパターンで作成したいと思います。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/491513/414ed8e2-91e4-8554-f7cb-787362a6e8a2.gif  width=50% height=50%>

#作成方法

###ライブラリの導入

```pubspec.yaml
environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.3

  freezed_annotation:
  flutter_state_notifier:
  state_notifier:
  provider:

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner:
  freezed:
```
今回はfreezed、state_notifier、providerを使ったアプリを作るのでそのライブラリを導入しています。
freezedに関しては事前知識があった方が理解が早いと思います。

###実装全体
まずは全体像を見て頂こうと思い、main.dart全体を載せました。
freezedを使用しているので、
`flutter pub pub run build_runner build`
このパブパブ言ってるコマンドを実行しておく必要があります。

実際のプロジェクトではファイルを分けた方が良いと思います。

流し見でもやっていることは単純だとお分かり頂けると思いますが、
各クラス個別に見ていこうと思います。

```main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:state_notifier/state_notifier.dart';
import 'package:provider/provider.dart';

part 'main.freezed.dart';

@freezed
abstract class CountState with _$CountState {
  const factory CountState({
    @Default(0) int count,
  }) = _CountState;
}

class CounterController extends StateNotifier<CountState> {
  CounterController() : super(const CountState());

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

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: StateNotifierProvider<CounterController, CountState>(
      create: (_) => CounterController(),
      child: HomePage(),
    ));
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              context.select<CountState, String>((s) => s.count.toString()),
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CounterController>().increment(),
        child: Icon(Icons.add),
      ),
    );
  }
}

```

####CountState
```dart
@freezed
abstract class CountState with _$CountState {
  const factory CountState({
    @Default(0) int count,
  }) = _CountState;
}
```
StateNotifierの肝の部分ですね。
カウントアップアプリで保持する状態は現在のカウント数ですから、
そのためのクラスです。イミュータブルなクラスになっています。

Defaultアノテーションで初期値を指定しています。

ここでfreezedを使用している理由が気になりますが、一旦先に進みます。

####CounterController
```dart
class CounterController extends StateNotifier<CountState> {
  CounterController() : super(const CountState());

  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}
```
名前のとおり、CountStateをコントロールするクラスです。
Controllerだったり、Notifierだったりと名前は好みで変わります。
increment()でカウント数を+1しています。

さて、ここで先程のCountStateに対してfreezedを使用する理由について説明します。
もし、freezedを使用せずイミュータブルなStateクラスを作成すると以下のように
状態を更新する必要が出てきます。

```dart
 void increment() {
   state = CountState(count: state.count + 1);
 }
```
何をしているかというと、CountStateクラスをいちいち作り直しています。
今回、CountStateクラスにはcount以外のメンバーはありませんが、
もし他にもメンバーあった場合、その他のメンバーまで作り直す必要が出てきます。
関係のないメンバーまで作り直すことを避けるためにfreezedのcopyWith()を使用して、
必要な箇所のみに変更を行う必要があるのです。

####MyApp
```dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: StateNotifierProvider<CounterController, CountState>(
      create: (_) => CounterController(),
      child: HomePage(),
    ));
  }
}
```
StateNotifierProviderを使用して、Widget(HomePage)にCounterControllerへのアクセスを生やしています。

####HomePage
```dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              context.select<CountState, String>((s) => s.count.toString()),
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CounterController>().increment(),
        child: Icon(Icons.add),
      ),
    );
  }
}
```
状態にアクセスし、画面へ反映しています。
状態へのアクセスにはメソッドを使い分け、なるべくリビルドの少ないコードを書く方が良いです。
以下は[こちらのページ](https://qiita.com/_masaokb/items/fe77495db0aeba226d2a)より引用したものです。
>
- `context.read<HogeStateNotifier>()`
  * State更新時にリビルドされない
  * StateNotifierのメソッドを呼びたい、Stateの値を更新したい時など
- `context.read<HogeState>()`
  * State更新時にリビルドされない
  * Stateの現在の値を参照したい（画面遷移で渡すなど）
- `context.select<HogeState, FugaClass>((s) => s.fuga)`
  * State更新時、fugaが変化している場合のみリビルドされる
  * context.select((HogeState s) => s.fuga)でもいいよ
  * 状態の変化をViewに反映させたいとき
- `context.watch<HogeState>()`
  * State更新時、常にリビルドされる
  * あまり使わないイメージ

今回は
状態をviewに反映させるために
`context.select<HogeState, FugaClass>((s) => s.fuga)`
CounterControllerのメソッドを呼ぶために
`context.read<HogeStateNotifier>()`
を使用しました。

これで各クラスの説明は以上です。

#所感
私はChangeNotifierとStateNotifier両方でこのカウントアップアプリを作成しましたが、
個人的にはChangeNotifierパターンの方が学習コストが低かったように思えます。
StateNotifierではfreezedでの実装がほぼ必須だと思っており、
freezedに対する学習が必要になるのが大きなところかなと。

この2つのパターンはさほど大きな差もないので、
初学者であれば、ChangeNotifierでの開発を経験してからStateNotifierへ移行するのもアリだと思いました。