Freezed が書いてくれるもの
同じ内容なら同じ値として扱いたい——その当たり前を Dart の素のクラスで満たすと、定型コードが増え続ける。Freezed が何を肩代わりしているのかを、値等価・copyWith・union・JSON 変換の順に、生成されるものの側から理解する。
ここまでは画面の話でした。ここからは、その画面が映しているデータの側に入ります。最初は、 データを表現するモデルの作り方です。
Flutter のコードを読んでいると、part 'user.freezed.dart'; という見慣れない行と、どこにも実装が
無いのに呼べる copyWith() に出会います。Freezed というコード生成の仕組みですが、これは
「便利だから使う」より先に、手で書くと何が大変なのかを知ったほうが腑に落ちます。そこから
始めます。
§ 01PROBLEM手書きモデルは何が増えていくのか
ユーザーを表す、ごく普通の不変クラスを Dart で書いてみます。
class User {const User({required this.id, required this.name, this.age});final String id;final String name;final int? age;User copyWith({String? id, String? name, int? age}) =>User(id: id ?? this.id, name: name ?? this.name, age: age ?? this.age);@overridebool operator ==(Object other) =>other is User && other.id == id && other.name == name && other.age == age;@overrideint get hashCode => Object.hash(id, name, age);@overrideString toString() => 'User(id: $id, name: $name, age: $age)';}
フィールドは 3 つなのに、それを 5 箇所に書いています。コンストラクタ、copyWith、==、
hashCode、toString。ここに JSON 変換を足せば 7 箇所です。
問題は量ではなく、壊れ方が静かなことです。フィールドを 1 つ増やしたときに == の更新を
忘れても、コンパイルは通ります。ただ「内容が違うのに等しいと判定される」オブジェクトが生まれ、
UI が更新されない、という形で後から出てきます。人間が同期させ続ける前提の設計が、そもそもの
弱点です。
§ 02VALUE値等価という考え方
なぜ == をわざわざ書くのか。Dart のクラスは既定で参照等価——同じ内容でも、別々に作った
インスタンスは等しくないからです。
const a = User(id: '1', name: 'みぎわ');const b = User(id: '1', name: 'みぎわ');// == を書いていなければ a == b は false
これが効いてくるのが状態管理です。連載 I で見たとおり、Riverpod は状態が変わったときに
再構築します。その「変わった」の判定は == です。値等価が無いと、中身がまったく同じ値を作り直す
たびに「変わった」と見なされ、無駄な再構築が起きます。逆に == を書き忘れたフィールドが
あれば、変わったのに再構築されない——今度は画面が古いままになります。
つまり == と hashCode は飾りではなく、UI が正しく更新されるための土台です。だからこそ、
手で書き続けるのが危ういわけです。
§ 03FREEZED宣言だけ書いて、あとは生成する
Freezed は、この定型を丸ごと引き受けます。書くのはフィールドの宣言だけです。
import 'package:freezed_annotation/freezed_annotation.dart';part 'user.freezed.dart';@freezedabstract class User with _$User {const factory User({required String id,required String name,int? age,}) = _User;}
読み方を分解します。
part 'user.freezed.dart'… 生成されるファイルをこのファイルの一部として取り込む宣言。 ファイル名は元ファイル名から決まりますwith _$User… 生成側が用意する mixin。==、hashCode、copyWith、toStringはここから来ますconst factory User({...}) = _User… 実装は生成されたクラス_Userに委ねる、という宣言。constが付いているので、const User(...)と書けば連載 I で見たconstの利点も得られます
生成は build_runner が行います。
dart run build_runner build --delete-conflicting-outputs
これで user.freezed.dart が作られ、先ほど手で書いた 5 箇所ぶんが埋まります。宣言が唯一の
source of truth になり、フィールドを足せば全部が追随します。
なお、@freezed を付けるクラスを abstract class として宣言するのは Freezed 3 系の書き方です。
2 系は素の class でした1Freezed 3 系では @freezed を付けるクラスを abstract class、union を sealed class として宣言する。2 系はどちらも素の class だったため、既存コードやより古い記事では書き方が異なる。。既存のコードで class のまま書かれていても、意味は同じものだと
読み替えてください。
§ 04COPYWITH不変のまま「一部だけ違う」値を作る
不変にすると、値を書き換えられません。代わりに使うのが copyWith です。
final updated = user.copyWith(name: '新しい名前');
「name だけ差し替えた新しい User」が返ります。元の user は変わりません。
ここで、手書き版には実は落とし穴がありました。冒頭のコードは name ?? this.name と書いて
いたので、copyWith(age: null) と明示的に null を渡しても「指定しなかった」と区別できず、
元の値が残ります。「値を消す」操作が書けないわけです。
Freezed が生成する copyWith は、内部で番兵を使ってこの二つを区別します。渡さなければ据え置き、
null を渡せば null になる——手で書くと面倒な部分が、正しく生成されます。
§ 05UNION状態を型で表す
Freezed のもう一つの顔が union(sealed class)です。「取りうる状態がいくつかあり、そのどれか 一つ」を型として表現します。
@freezedsealed class LoadResult with _$LoadResult {const factory LoadResult.loading() = Loading;const factory LoadResult.data(List<User> users) = Data;const factory LoadResult.failure(String message) = Failure;}
受け取る側は Dart の switch で分岐します。
final label = switch (result) {Loading() => '読み込み中',Data(:final users) => '${users.length} 件',Failure(:final message) => 'エラー: $message',};
sealed の効き目はここです。取りうる型が出そろっていることをコンパイラが知っているので、
分岐を書き漏らすとコンパイルエラーになります。状態を増やしたとき、対応漏れが実行時ではなく
その場で分かる——これが if の連鎖に対する優位点です。
連載 I で扱った AsyncValue も、まさにこの形でした。loading / data / error を型として持ち、
分岐して描く。あちらは Riverpod が用意してくれた型です。自分のドメインで同じ構造が要るときに、
Freezed の union を使います22 系が生成していた when / map は、Dart 3 の switch によるパターンマッチが入ったことで役目を終えた。3 系では switch を使うのが標準で、網羅性の検査もコンパイラ側が担う。。
§ 06JSONサーバーとの境界
API から届く JSON をモデルへ変換する部分も生成できます。json_serializable を組み合わせ、
part 'user.g.dart'; と fromJson を足します。
part 'user.freezed.dart';part 'user.g.dart';@freezedabstract class User with _$User {const factory User({required String id,@JsonKey(name: 'display_name') required String name,@Default(0) int age,DateTime? createdAt,}) = _User;factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);}
@JsonKey(name: 'display_name')… JSON 側のキー名が違うときの対応付け。サーバーが snake_case、 Dart 側が camelCase、という食い違いをここで吸収します@Default(0)… キーが無いときの既定値。これがないと必須扱いになりますDateTimeは ISO 8601 文字列との相互変換が自動で入ります
toJson() も同時に生成されるので、送る側も書かずに済みます。変換規則がモデルの宣言の中に
書いてある——JSON の形が変わったとき、直す場所が一箇所に決まるのが利点です。
§ 07BUILDRUNNER生成物との付き合い方
最後に運用面を三つ。
書き換えない。 .freezed.dart と .g.dart は生成物です。手で直しても次の生成で消えます。
直したくなったら、直す先は元の宣言のほうです。
watch を使う。 モデルを編集するたびに手で生成するのは面倒なので、開発中は監視させます。
dart run build_runner watch --delete-conflicting-outputs
--delete-conflicting-outputs は、前回の生成物が残っていて衝突したときに消して作り直す指定です。
付けないと「conflicting outputs」で止まることがあります。
リポジトリに含めるかを決めておく。 生成物をコミットすれば、clone 直後や CI で生成を待たずに ビルドでき、生成器のバージョン差による差分もレビューで見えます。代わりに差分が大きくなります。 どちらも成立しますが、チームで統一されていないと衝突の種になるので、最初に決めます。
§ 08SUMMARYFreezed を読む地図
- 不変モデルを手で書くと、フィールド 1 つが5 箇所以上に散る。しかも壊れ方が静か
- Dart の既定は参照等価。値等価が無いと、UI が無駄に再構築されるか、更新されない
@freezed+const factoryを宣言すれば、==/hashCode/copyWith/toStringが生成される- 生成された
copyWithは「渡さなかった」と「null を渡した」を区別できる sealed classの union は、分岐漏れをコンパイルエラーにする- JSON は
@JsonKey/@Defaultで規則を宣言に書き、fromJson/toJsonを生成する - 生成物は編集しない。
watchで回し、コミットするかは最初に決めておく
モデルが定まったので、次回はそれをどこに置くかです。SharedPreferences・SQLite・Secure Storage の使い分けと、端末内にデータを持つということの意味を扱います。