【Flutter】StateNotifier+freezedパターンのメリットを理解しようと試みた

Flutter界隈のすごい人達が「StateNotifier+freezedパターンは良い」と言っているのですが

調べてみても、残念ながら筆者にはメリットが理解できませんでした。


でもそれでは悔しいので！


色々実験して「なるほどこれは確かにすごい」と言えるようになろう！


という記事です。


ちなみに、メリットを理解できていない理由はだいたい見当がついていて、筆者が弱小個人開発者にすぎず、大した規模のアプリを作っていないからだと思います。プログラミングで誰かと一緒に仕事をしていません。だから複雑性があまりなく、メリットを享受しにくいというワケ。

(これだけ日々プログラミングしてるんだから誰かと仕事したら？という感じはしますね。すべきだろうな…)


# メリットを調査してみる


調べてみると、Viewと状態とロジックの3者を分離できることがメリットっぽいです。

ChangeNotifierだと、ロジックと状態は分離されず、一つのクラス内に書くことになるので。

ただ、「状態とロジックを分離できること」のメリットがわからない！

結局いくら調べても「なるほど！それならStateNotifier+freezedパターンを使うべきだな！」と納得することはできませんでした。

ちなみにChangeNotifierパターンでもStateNotifier+freezedパターンでも、Viewは他から分離されます。

## (追記)メリットについて修正

3者を分離する書き方はChangeNotifierでも可能なので、「3者を分離できること」自体はStateNotifier+freezedのメリットとは言えない、というご指摘を頂きました。詳しくはコメント欄を御覧ください。

なお「stateがimmutableになることがメリット」と教えていただいたのですが、これの何がメリットなのか筆者は現在調査中です。しばらく調べましたがまだピンときてないです。これも、複雑なアプリを作っていないことと複数人で作業していないことが原因だと思います。



# デメリットなら知ってる

StateNotifier+freezedパターンのデメリットはこちら。

 - 追加パッケージが複数必要になる(state_notifier, freezed)
 - freezedを使って自動コード生成を走らせる行程が増える


追加パッケージが増えると、自分の開発活動が、Flutter本体だけでなくそのパッケージの開発状況にも影響されてしまうので、基本的に良くないです。

何らかの事情でそのパッケージが使えなくなった場合、対応に追われることになります。

また、複数人チームでそのパッケージを採用した場合、皆に学んでもらう工数が発生します。


自動コード生成を走らせる行程は、単に工数が増えるのでデメリットです。

それを超えるメリットがStateNotifier+freezedパターンにはあるはずだ、ということになりますね。

その工程を自動化出来たとして、自動化する手間はかかってるわけですし、仮にそれを他の担当者にも採用してもらうなら学習してもらう工数が発生しますね。それがどの程度のものなのかは測りかねますが…。





# 検証用アプリを作る

ということで、ウダウダ言ってないで実際にアプリを作って比較してみることにしましょう！

ChangeNotifierパターンとStateNotifier+freezedパターンで比較していきます。

## Riverpod使います

ChangeNotifierかStateNotifierか、というのは、状態管理クラスとして何を使うかという話であって、あともう一つ、その状態管理クラスをアプリに渡す方法を決める必要があります。

現在主流なのはProviderを使う方法だと思うのですが、今回はProviderパッケージと同じ作者による最新(2020年8月9日現在)手法Riverpodを使っていこうと思います。

Providerを使うかRiverpodを使うかは、ChangeNotifierとStateNotifier+freezedの比較には影響しないはずです。なので今回の比較結果はProviderを使う場合にも通じるはず。


## 要請

StateNotifierの良さを際立たせるためにはある程度複雑性が必要なはずです。

どの程度複雑にする必要があるのかはよくわかっていない(それがわかってたらメリットわかるはず)ので、とりあえず次のようにします。


 - 画面に反映される、変更されるプロパティを複数持つ
 - 画面に反映されない、変更されるプロパティを持つ
 - メソッドを複数持つ
 - RepositoryをDIされる


こんなところでしょうか。

## アプリ

というわけでこんなものを考えてみました

![スクリーンショット 2020-08-07 22.09.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/d0d729cf-dc5d-1289-bcd7-ed1128878bb3.png)


2種類の整数を増やしたり減らしたりして、目的の整数を作るゲームです。

そこそこの暗算力が必要になります。

クリア回数はSharedPreferencesを使って端末に記録し、アプリ起動時に毎回ロードすることにします。

また、画面に表示されていませんが、ボタンを押した回数を記録しています。

次のように、上記の要請を満たしています。

 - 画面に表示される、変更されるプロパティ
    - 整数X、整数Y、クリア回数 など
 - 画面に反映されない、変更されるプロパティ
    - ボタンを押した回数(何も使ってないけど…)
 - メソッド
    - 整数を変更するメソッド
    - クリア判定&クリア処理メソッドなど
 - RepositoryをDI
    - SharedPreferencesへのアクセスを担当するRepository

要請は満たしてますね。

要請を満たすためだけに「ボタンを押した回数」とかいう使ってないプロパティを作ってしまいました。無駄にボタン押すと得点が減るようにすると面白いかな？


# Github Repositoryに置きました

こちらのGithub repositoryにあります。

https://github.com/agajo/bezout_qiita

通常エントリーポイントとなる`main.dart`がありません。

`change_pattern/main.dart`か`state_pattern/main.dart`をエントリーポイントに指定してください。

それぞれ、ChangeNotifierパターンとStateNotifier+freezedパターンのアプリが起動します。アプリの内容は同じです。

## ソースコード

### ChangeNotifier

まずはChangeNotifierのソースコードです。

ざっと眺めると、画面に反映されるプロパティと、それらを操作するメソッドが全てこのクラス内にあります。

随所で`notifyListeners()`している点にも注目。

```dart:game_notifier.dart
import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';

import '../count_repository.dart';

class GameNotifier extends ChangeNotifier {
  GameNotifier({CountRepository countRepository})
      : _countRepository = countRepository ?? CountRepository() {
    makeNextGame();
    _countRepository.load().then((value) {
      clearedCount = value;
      notifyListeners();
    });
  }
  int a = 0, b = 0, x = 0, y = 0, target = 0;
  int clearedCount = 0;
  int pressedCount = 0;
  bool justCleared = false;
  final CountRepository _countRepository;

  void makeNextGame() {
    do {
      // aとbは2以上
      a = Random().nextInt(30) + 2;
      b = Random().nextInt(30) + 2;
      target = Random().nextInt(200);
    } while (target % a.gcd(b) != 0); // クリア可能じゃなかったらa,b,targetを再生成
    notifyListeners();
  }

  void checkCleared() {
    if (a * x + b * y == target) {
      justCleared = true;
      notifyListeners();
      Timer(const Duration(milliseconds: 3000), () {
        justCleared = false;
        ++clearedCount;
        x = 0;
        y = 0;
        _countRepository.save(clearedCount);
        makeNextGame();
      });
    }
  }

  void changeXY({@required bool isX, @required int value}) {
    if (isX) {
      x += value;
    } else {
      y += value;
    }
    ++pressedCount;
    notifyListeners();
    checkCleared();
  }
}
```
### StateNotifier+freezed

続いてStateNotifierのソースコードです。

画面に反映されるプロパティがありません。

代わりにメソッド内では、`state`を毎回`state.copyWith`というメソッドを用いて作り直し、置き換えています。それだけで画面に変更が反映されます。

`notifyListeners()`はありませんね。

行数はChangeNotifierの`game_notifier.dart`とほぼ同じです。

```dart:game_controller.dart
import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:state_notifier/state_notifier.dart';

import '../count_repository.dart';
import 'game_state.dart';

class GameController extends StateNotifier<GameState> {
  GameController({CountRepository countRepository})
      : _countRepository = countRepository ?? CountRepository(),
        super(GameState()) {
    makeNextGame();
    _countRepository.load().then((value) {
      state = state.copyWith(clearedCount: value);
    });
  }
  final CountRepository _countRepository;
  int pressedCount = 0;

  void makeNextGame() {
    int a, b, target;
    do {
      // aとbは2以上
      a = Random().nextInt(30) + 2;
      b = Random().nextInt(30) + 2;
      target = Random().nextInt(200);
    } while (target % a.gcd(b) != 0); // クリア可能じゃなかったらa,b,targetを再生成
    state = state.copyWith(a: a, b: b, target: target);
  }

  void checkCleared() {
    if (state.a * state.x + state.b * state.y == state.target) {
      state = state.copyWith(justCleared: true);
      Timer(const Duration(milliseconds: 3000), () {
        state = state.copyWith(
            justCleared: false,
            clearedCount: state.clearedCount + 1,
            x: 0,
            y: 0);
        _countRepository.save(state.clearedCount);
        makeNextGame();
      });
    }
  }

  void changeXY({@required bool isX, @required int value}) {
    if (isX) {
      state = state.copyWith(x: state.x + value);
    } else {
      state = state.copyWith(y: state.y + value);
    }
    ++pressedCount;
    checkCleared();
  }
}
```

freezedで自動コード生成の元にするコードはこちら。

画面に反映されるプロパティはすべてこちらに置いてあります。

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

part 'game_state.freezed.dart';

@freezed
abstract class GameState with _$GameState {
  factory GameState({
    @Default(0) int a,
    @Default(0) int b,
    @Default(0) int x,
    @Default(0) int y,
    @Default(0) int target,
    @Default(0) int clearedCount,
    @Default(false) bool justCleared,
  }) = _GameState;
}
```


# 作っていく上で気付いた違い

## StateNotifier+freezedのメリットについて

### `notifyListeners()`忘れがない

ChangeNotifierパターン作ってる時にやりがちなミスです。今回もやりました。ボタンが反応しなくて「あれ？」となりました。

これが発生しないのは一つメリットですね。



### StateNotifierの`state`が持っている中身が、Flutter Inspectorで確認できる

<img width="544" alt="スクリーンショット 2020-08-09 9.52.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/fc09bb13-79e2-0fe0-05a8-fa8efe75c875.png">


こんな感じです。便利ですね。

これは、@freezedを書くコードの冒頭で

```dart:game_state.dart
import 'package:flutter/foundation.dart';
```

として`foundation.dart`をインポートしておくと利用できるようです。

参考
https://pub.dev/packages/freezed#run-the-generator


### 画面状態とロジック(メソッド)が分離されたことで頭がスッキリする気がする

画面に反映されるプロパティたちと、それを操作するメソッドたちが完全に分離されたことで、頭がゴチャゴチャしにくそう…？かな…？慣れてくるとこれが最大のメリットに感じられるようになってくる…かな？既にそう感じてる感もあるようなないような…。

## StateNotifier+freezedのデメリットについて

### 追加パッケージの学習コスト

予想していたことですが、freezedとbuil_runnerパッケージの学習コストはやはりあると思いました。

build.yamlの書き方とか新しく知る必要がある。

```build.yaml
targets:
  $default:
    builders:
      freezed:
        generate_for:
          include:
            - lib/**/*state.dart
```

freezedの書き方も特殊です。factoryを使うところとか、デフォルト値の指定とか。

```dart:game_state.dart
@freezed
abstract class GameState with _$GameState {
  factory GameState({
    @Default(0) int a,
    @Default(0) int b,
    @Default(0) int x,
    @Default(0) int y,
    @Default(0) int target,
    @Default(0) int clearedCount,
    @Default(false) bool justCleared,
  }) = _GameState;
}
```

やり方を理解して実際に書くまで1時間くらいはかかるんじゃないでしょうか。

### 追加パッケージのバージョンコンフリクトのリスク

また、バージョンによるトラブルにも遭遇しました。

たまたま時期が悪く、build_runner最新版(1.10.2)とFlutter(stable channel)の最新版が利用するDart SDK(2.9)に互換性がないせいで無用なトラブルに巻き込まれた結果、これがデメリットであるという印象が強まっているのかも知れません。

というか、build_runnerのバージョンを1.10.1に下げれば良かったところを雑に1.9.xにまで下げた筆者がかなり悪いので、これは無視できるリスクなのかもしれません。

### 自動コード生成を走らせる手間

手間が発生するのは間違いないのでデメリットではありますが、この手間はそんなに多くないです。freezedの書き方を理解して、バージョンコンフリクトも解決した後なら。

コマンド一発だし、build.yamlで対象を指定しておけば時間も短い。

「この行程が必要なことを覚えておく」という手間(?)が発生する気もしましたが、この行程をしないとそもそも動かないので、頑張って覚えておく必要はないですね。

忘れてもアプリが動いてしまう`notifyListeners()`とはその辺が違います。


## その他気付いたこと

 - ChangeNotifierパターンで作ってから、それをコピペしてStateNotifier+freezedパターンに書き換えたのですが、わりと機械的に書き換えるだけですぐ動くようになりました。

他にも思い出したら追記します。

# 今回検証してないこと

## 仕様変更のしやすさ
アプリの仕様を変更する際のやりやすさの比較もできるといいかなと思いつつ、今回はやれていません。そこに差はでないかな…？

## Widgetのリビルド制御

Widgetのリビルドを細かく制御しようとすると、何か差が出るかも知れません。今回はあまり気にしませんでした。

筆者としては、FlutterのWidgetのリビルドは(エレメントを再生成しない限り)かなり軽量なので、気にしてもパフォーマンスに差は出ないと考えています。よほど大規模で繊細なアプリなら別なのかも知れません。

## 追加や変更通知の時間計算量のオーダー

こちらに、時間計算量のオーダーを改善したことがメリットとして上げられています。

https://pub.dev/packages/state_notifier#differences-with-valuenotifier

ですが、今回のような小規模アプリではまったく差になっていません。

どの程度のアプリから問題になってくるんだろう…。ほぼ問題になることはないのでは…。

アニメーションとかで毎フレーム変更通知したりすると問題になるかも。(それってChangeNotifierやStateNotifierでやることなのか？)


## テスタビリティ

これは大事ですが、今回ちゃんと検証してません。もしかしたら何かわかりやすい差があるかも知れません。

でも正直、差がある気はしてません。stateとロジックを分離したことでロジックだけを簡単にテストできたりするのか…？


# 結論

ということで、今回の検証では「明らかにChangeNotifierよりStateNotifier+freezedの方が良いぞ！！！！」と主張できる根拠は得られませんでした。

一方、懸念していた「自動生成」「追加パッケージ」も、いざやってみると大したデメリットではありませんでした。

ということでまぁ…

つまらない結論ですが…

現段階では「どっちでもいい」ということになってしまいますね。


## 筆者は今後どうするの

筆者としては、せっかく学習コストを払ったので、Riverpod+StateNotifier+freezedというパターンをしばらく使ってみようと思います。

検証用アプリではなく、もっとちゃんと複雑性のあるアプリを作った時に、「stateとロジックが分離されることで頭がスッキリする」が効いてくる気がしています。

(追記)また、stateをimmutableにすることのメリットもだんだんわかってくるといいなと思っています。

それらを実感できてきたらこの記事に追記しようと思います。