4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterのFreezedとstate_notifierを試してみたら、噂通り良さげだった

Posted at

:book: Flutterの記事を整理し本にしました :book:

  • 本稿の記事を含む様々な記事を体系的に整理し本にまとめました
  • 今後はこちらを最新化するため、最新情報はこちらをご確認ください
  • 25万文字を超える超大作になっています!!

まとめ記事

はじめに

  • FlutterのFreezedとstate_notifierを試してみたら、噂通り良さげだったでした。
  • いろいろ試したことをまとめました。

Freezed

immutableとmutable

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

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

z9v1hz4klvi1jxzmyxygu1l7jay3.png

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

stj9vxbi12phyo4gvrxjrz9jr1k7.png

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

今回は、フィールドがString,intといった単純なものなので、finalやsetter,getterなどのカプセル化でimmutable化は実現できますが、フィールドに別のクラス/配列/ハッシュ/リストなどを持つことも考えられ、その場合には、immutable化する制御はもう少し難しくなります。

実装と動作確認

まず、必要なパッケージを入れます
freezedはbuild_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';

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

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

user.freezed.dart_$Userは自動生成されるファイルやクラスなので、この時点ではエラーになっていますが問題ありません。
後述の自動生成を行うことでエラーが解消します。

続いて自動生成を行います。
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というファイルが作成されていれば成功です。

--delete-conflicting-outputsは不整合などが起きた時に必要となるオプションで、最初などは無くても動作しますが、付けておくほうが無難です。

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

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
// 抜粋
@override
  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)));
  }
  @override
  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

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

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

Providerデザインパターンがよくわからないという方は、先に開発の上級4:Providerデザインパターン(Provider/Notify)のチャプターをご覧頂くと理解が深まるかと思います。

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

vpor23d7oo60cyes7lsoliqn079w.png

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

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';

@freezed
class MyValue with _$MyValue {
  const factory MyValue({@Default(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 {
  @override
  createState() => _MyValueSliderState();
}

class _MyValueSliderState extends State<MyValueSlider> {
  @override
  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 {
  @override
  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;
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  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()
          ],
        ));
  }
}

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

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

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

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?