Chapter 30

上級編7:freezedとstate_notifier

kazutxt
kazutxt
2021.05.15に更新

このチャプターでは、クラスをImuutableにするFreeedについて解説します。

また、JSONシリアライズとの連携や、state_notifierを用いたデザインパターンについても解説します。

freezed

https://pub.dev/packages/freezed

immutableとmutable

インスタンスにはimmutableとmutableという考え方があります。
immutableは不変、mutableは可変という意味です。

例えば、Userというクラスがあり、nameageというフィールドを持つとします。
一般的な(mutableな)クラスとして定義されていれば、User user = User(name:"kazutxt",age:32)でインスタンスを生成した後に、user.age=32で値を書き換えることができます。
言い換えると、Userの状態は可変で、常に書き換えることができます。

一方で、immutableなクラスとする場合は、下図のように値を書き換えることができず、コピー/クローンをした上で、値を変更して利用します。

immutableは一見書き換えができず不便にみえるかもしれませんが、値が変わらないことを保証できるため、思わぬ副作用や修正による影響調査を簡略化できるなどのメリットがあります。

実装と動作確認

まず、必要なパッケージを入れます
freezedbuild_runnerというパッケージでソースコードを自動生成して利用します。

pubspec.yaml
dependencies:
  freezed_annotation:

dev_dependencies:
  build_runner:
  freezed:

続いて、モデル(クラス)の定義を行います。

user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'user.freezed.dart';


class User with _$User {
  const factory User(String name, int age) = _User;
}

@freezedをつけることで自動生成の対象となります。

続いて自動生成を行います。
flutter pub run build_runner build --delete-conflicting-outputsをコマンドラインから実施します。

result.sh
% flutter pub run build_runner build --delete-conflicting-outputs
[INFO] Generating build script...
[INFO] Generating build script completed, took 600ms

[INFO] Initializing inputs
[INFO] Reading cached asset graph...
[INFO] Reading cached asset graph completed, took 100ms

[INFO] Checking for updates since last build...
[INFO] Checking for updates since last build completed, took 1.4s

[INFO] Running build...
[INFO] 1.2s elapsed, 1/2 actions completed.
[INFO] 2.2s elapsed, 1/2 actions completed.
[INFO] 3.3s elapsed, 1/2 actions completed.
[INFO] 4.3s elapsed, 1/2 actions completed.
[INFO] 15.4s elapsed, 1/2 actions completed.
[WARNING] No actions completed for 15.0s, waiting on:
 - freezed:freezed on test/widget_test.dart
 - freezed:freezed on lib/user.dart

[INFO] Running build completed, took 15.7s

[INFO] Caching finalized dependency graph...
[INFO] Caching finalized dependency graph completed, took 60ms

[INFO] Succeeded after 15.8s with 0 outputs (2 actions)

エラーが発生せずに、user.freezed.dartというファイルが作成されていれば成功です。

今回は画面などは関係ないため、確認用のメソッドのみ掲載します。

freezed_check.dart
void func() {
    // ユーザを3つ作成する。user1とuser3は同じ内容
    User user1 = User('kazutxt', 30);
    User user2 = User('kazutxt2', 32);
    User user3 = User('kazutxt', 30);

    // 表示(toString)
    print("check1");
    print(user1);
    
    // 比較(==)
    print("check2");
    if (user1 == user2) print("user1とuser2は同じ人");
    if (user1 == user3) print("user1とuser3は同じ人");
    
    // コピーをして新しいインスタンスを作成(user1は変わらない)
    print("check3");
    User user4 = user1.copyWith(name: "unknown");
    print(user1);
    print(user4);
    
    // 参照そのものを変えるのはOK
    print("check4");
    user2 = user3;
    print(user2);

    // immutableを破壊するので、以下のような使い方はNG
    // user1.name = "unknown";
  }

動作結果は以下のようになります。

result.sh
I/flutter (16662): check1
I/flutter (16662): User(name: kazutxt, age: 30)
I/flutter (16662): check2
I/flutter (16662): user1とuser3は同じ人
I/flutter (16662): check3
I/flutter (16662): User(name: kazutxt, age: 30)
I/flutter (16662): User(name: unknown, age: 30)
I/flutter (16662): check4
I/flutter (16662): User(name: kazutxt, age: 30)

printでは、toStringが呼び出され文字列化されるのですが、このtoStringも自動生成されてるためUser型の内容を表示できています。

該当の定義部分を見てみると、以下のようになっています。

user.freezed.dart
// 抜粋
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
    return 'User(name: $name, age: $age)';
}

比較や同一性の確認については、==hashCodeを用いて行われます。

これらも自動生成されます。

各要素ごとに比較したり、各要素のハッシュ値を求めてXORで計算したりしています。

user.freezed.dart
// 抜粋

  bool operator ==(dynamic other) {
    return identical(this, other) ||
        (other is _User &&
            (identical(other.name, name) ||
                const DeepCollectionEquality().equals(other.name, name)) &&
            (identical(other.age, age) ||
                const DeepCollectionEquality().equals(other.age, age)));
  }
  
  int get hashCode =>
      runtimeType.hashCode ^
      const DeepCollectionEquality().hash(name) ^
      const DeepCollectionEquality().hash(age);

JSON_Serializableとの連携

freezedにはJSONに簡単に変換する仕組みが提供されています。
コードに数行加えるだけで、JSONからのクラスの生成や、クラスのJSON化が実現できます。

まずは、jsonのパッケージを追記します。

pubspec.yaml
dependencies:
  freezed_annotation:
+ json_serializable:

dev_dependencies:
  build_runner:
  freezed:

続いて、元になるファイルに情報を追記します。

user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'user.freezed.dart';
+ part 'user.g.dart';

@freezed
class User with _$User {
  const factory User(String name, int age) = _User;
+  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

再度コマンドを再度実行します。
flutter pub run build_runner build --delete-conflicting-outputs

動作を確認してみます。

freezed_check.dart
void func2() {
    //String→Map→User
    String jsonString = '{"name":"kazutxt","age":30}';
    User fromJsonUser = User.fromJson(json.decode(jsonString));
    print(fromJsonUser);

    //User→Map→String
    User toJsonUser = User('kazutxt2', 32);
    Map<String, dynamic> jsonData = toJsonUser.toJson();
    print(jsonData);
}

動作結果は以下のようになります。

result.sh
I/flutter (16662): User(name: kazutxt, age: 30)
I/flutter (16662): {name: kazutxt2, age: 32}

fromJSON,toJsonともに自動生成され以下のような実装になっています。

user.g.dart
_$_User _$_$_UserFromJson(Map<String, dynamic> json) {
  return _$_User(
    json['name'] as String,
    json['age'] as int,
  );
}
Map<String, dynamic> _$_$_UserToJson(_$_User instance) => <String, dynamic>{
      'name': instance.name,
      'age': instance.age,
    };

Freezedのメリットのまとめ

  • imutableなクラスが作れる
  • copyWith,==,toString,hashCodeが自動的に作成される
  • FromJson/ToJsonが自動的に作成され、JSONへの変換が簡単に行える

state_notifier

https://pub.dev/packages/state_notifier

このfreezedはstate_notifierと相性がよく、Providerデザインパターンをより改善することができます。

state_notifierを用いたデザインパターンも基本的にProviderと同じ考え方になっています。
上位にStateNotifierProviderを入れておき、下位のWidgetから参照できるようにします。
Consumerを入れる必要が減る分、実装がシンプルになります。

上級編5:Providerデザインパターンで作成したスライダとその値を表示するシンプルなアプリをFreezed&StateNotifierを使って作り直してみます。

実装と動作確認

まずは、必要なパッケージを入れます。

pubspec.yaml
dependencies:
  state_notifier: ^0.7.0
  flutter_state_notifier: ^0.7.0
  provider: "5.0.0"

次に、MyValueとその変更が会った時にどのような処理を行うのかのロジック部分を実装します。
この時、MyValueはimmutableなので、前の状態からcopyWithでコピーをして差分の部分を新しく与えて次の状態を作り出しています。

MyValue.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
import 'package:state_notifier/state_notifier.dart';

part 'myvalue.freezed.dart';


class MyValue with _$MyValue {
  const factory MyValue({(0.0) double value}) = _MyValue;
}

class MyValueStateNotifier extends StateNotifier<MyValue> {
  MyValueStateNotifier() : super(MyValue());
  change(newValue) => state = state.copyWith(value: newValue);
}

続いて、Sliderになります。
context.selectでMyValueの値の取得と、context.readで値の変更・反映を行っています。
context.select/context.readの考え方はProviderと変わっておらず下記のとおりです。

  • context.select(変更を監視する)
  • context.read(変更を監視しない)
MyValueSlider
import 'package:flutter/material.dart';
import 'package:hello_world/myvalue.dart';
import 'package:provider/provider.dart';

class MyValueSlider extends StatefulWidget {
  
  createState() => _MyValueSliderState();
}

class _MyValueSliderState extends State<MyValueSlider> {
  
  Widget build(BuildContext context) {
    return Slider(
        value: context.select((MyValue myValue) => myValue.value),
        onChanged: (newValue) =>
            {context.read<MyValueStateNotifier>().change(newValue)});
  }
}

最後にこれらを合わせたmainの画面です。

main.dart
import 'package:flutter/material.dart';
import 'package:hello_world/myValueSlider.dart';
import 'package:provider/provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:hello_world/myvalue.dart';

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

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: StateNotifierProvider<MyValueStateNotifier, MyValue>(
          create: (_) => MyValueStateNotifier(),
          child: MyHomePage(title: 'Flutter Demo Home Page'),
        ));
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, this.title}) : super(key: key);
  final String? title;
  
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title!),
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            //Consumer<MyValue>( ... // Providerの場合
            Text(
                context
                    .select<MyValue, double>((state) => state.value)
                    .toStringAsFixed(2),
                style: TextStyle(fontSize: 100)),
            MyValueSlider()
          ],
        ));
  }
}

state_notifierのメリットのまとめ

Consumerがなくなった分スッキリしたのがわかるかと思います。
TextはSliderのvalue部分と同じく値の取得を行っています。

入力、処理、出力が以下のように分離できています。

入力:SliderのonChanged
処理:MyValueStateNotifierのchange
出力:Text及びSliderのvalue