immutableなデータモデルの管理
はじめに
データモデルが変更可能(mutable)である場合、予期しない変更やデータの不整合が発生し、これがアプリケーションのバグの原因になることがある。一方で、immutableなデータモデルの管理を行えば一度作成されたデータが変更されないことで、安全性が保証され、状態管理がシンプルになる。
さらに、Flutterにおいては、状態管理のライブラリであるRiverpodが状態の変更を「新しい状態の作成」として扱うという点で、immutableなデータモデルとの相性が良い。
ここではfreezedを使ったimmutableなデータモデルを設計・管理する方法とFlutterアプリにおける実践方法を扱う。
freezedとは
- Flutterでimmutableなデータモデルを作成するためのライブラリ
- JSONシリアライズ、copyWithメソッド、型安全な分岐などもサポート
freezedでのimmutableなデータモデルの実現
1. 必要なパッケージをインストール
dependencies:
freezed_annotation: ^2.3.0
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.3.0
freezed: ^2.3.0
json_serializable: ^6.7.0
2. 生成時間の短縮と生成ファイルの管理
大規模なプロジェクトやファイル数が多い場合、build_runnerの実行時間が長くなることがある。これを短縮するため、build.yamlを設定してスキャン対象を限定する。
デフォルトでは、build_runner はプロジェクト内のすべてのファイルをスキャンする。
しかし、build_extensions で出力先を限定すると、それに対応する入力ファイルだけがスキャン対象となる。
- プロジェクトのルートディレクトリにbuild.yamlを作成
- 以下の内容を記述する
targets:
$default:
builders:
source_gen|combining_builder:
options:
build_extensions:
'^lib/model/{{}}.dart': 'lib/model/generate/{{}}.g.dart'
freezed:
options:
build_extensions:
'^lib/model/{{}}.dart': 'lib/model/generate/{{}}.freezed.dart'
- 入力ファイルの指定
^lib/model/{{}}.dart
は、lib/model ディレクトリ配下のすべてのDartファイルを入力対象とする。例えば、lib/model ディレクトリにモデルが集中していれば、他のディレクトリ(例: lib/service や lib/view)は無視される。lib/model/todo/
など適宜カスタマイズして実行するのも良い。 - 出力先を特定のディレクトリに指定
生成ファイル(例: g.dart や freezed.dart)は lib/model/generate に保存される。
入力ファイルごとに対応する生成ファイルが、このディレクトリにまとめられるようになる。
大変参考になりました
3. データモデルの作成
例: lib/model/todo.dart
にデータモデルを記述
import 'package:freezed_annotation/freezed_annotation.dart';
part 'generate/todo.freezed.dart';
part 'generate/todo.g.dart';
class Todo with _$Todo {
const factory Todo({
required String id,
required String title,
(false) bool isCompleted,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
4. コード生成の実行
コマンドを実行して生成ファイルを作成:
$ flutter pub run build_runner build --delete-conflicting-outputs
--delete-conflicting-outputs
は、競合する出力ファイルを削除して再作成するオプションである。
上記の設定では、生成ファイルがlib/model/generateディレクトリにまとめて格納される。
lib/
└── - model
├── - generate
│ ├── - todo.g.dart
│ └── - todo.freezed.dart
└── - todo.dart
注意点
生成ファイルは直接編集しない。build_runnerの再実行で上書きされるため、例えば、todo.dartを編集してから再生成する。
freezedの多機能性
copyWith メソッドの自動生成
- 特定のフィールドだけを変更して新しいインスタンスを生成。
- プロパティの一部を簡単に上書きできる。
class User with _$User {
const factory User({
required String name,
required int age,
}) = _User;
}
void main() {
final user = User(name: 'John', age: 25);
final updatedUser = user.copyWith(age: 26);
print(updatedUser); // User(name: John, age: 26)
}
JSONのシリアライズ/デシリアライズ
- JSONをオブジェクトに変換(fromJson)。
- オブジェクトをJSONに変換(toJson)。
- APIやローカルストレージとのやり取りを容易にする。
class User with _$User {
const factory User({
required String name,
required int age,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
void main() {
final userJson = {'name': 'Alice', 'age': 30};
final user = User.fromJson(userJson);
print(user); // User(name: Alice, age: 30)
final json = user.toJson();
print(json); // {'name': 'Alice', 'age': 30}
}
等価比較
- freezed クラスのインスタンスは、プロパティの値がすべて一致していれば「等しい」と判断される。
- 通常の Dart クラスでは、
==
はオブジェクトの参照(メモリアドレス)を比較するが、freezed クラスでは値の内容を比較する。
class User with _$User {
const factory User({
required String name,
required int age,
}) = _User;
}
void main() {
final user1 = User(name: "Alice", age: 25);
final user2 = User(name: "Alice", age: 25);
print(user1 == user2); // true: 値が全て一致
}
型安全な分岐(Union Types / Sealed Classes)
Union Types:
- いろんな種類の状態をまとめて管理する方法。
- 例えば、「成功」「失敗」みたいな状態を1つのグループとして扱える。
Sealed Classes:
- 状態(種類)を決めて、それ以外の種類を作れないようにするルール。
- 例えば、「成功」「失敗」という種類だけを使えるように固定する。
Union Types は複数の型(または状態)を1つのグループとして管理する設計パターンであり、Sealed Classes はそれを実現するための基盤となる概念である。
class Result with _$Result {
const factory Result.success(String data) = Success;
const factory Result.error(String message) = Error;
}
void handleResult(Result result) {
result.when(
success: (data) => print('Success: $data'),
error: (message) => print('Error: $message'),
);
}
この記述に基づいて、build_runner を実行すると、次のようなコードも生成される
class Success extends Result {
final String data;
const Success(this.data);
// その他の自動生成されたコード(==演算子、hashCodeなど)
}
class Error extends Result {
final String message;
const Error(this.message);
// その他の自動生成されたコード(==演算子、hashCodeなど)
}
つまり:
- Success や Error は Result クラスのサブクラス として freezed によって生成される。
-
Result.success(String data) = Success;
という記述は、「Result.success コンストラクタを呼び出したときに Success クラスのインスタンスを作る」という意味である。
サーバーからデータを取得する処理(例)
Future<Result> fetchData() async {
try {
final data = await Future.delayed(Duration(seconds: 1), () => "Fetched Data");
return Result.success(data); // 成功時
} catch (e) {
return Result.error(e.toString()); // エラー時
}
}
void main() async {
final result = await fetchData();
handleResult(result); // 結果を処理
}
Union Types の例は Sealed Classes の例にもなる
freezed を使うと、Union Types を定義した時点で Sealed Classes としての特性も得られる。
class CustomState extends Result {} // エラーになる
Result は実質的に Sealed Classes であり、外部で新しい状態を追加することはできない。
違いは目的
Union Types: 状態をグループ化し、型安全に管理する設計パターン。
Sealed Classes: 状態(クラス)の種類を固定して外部からの変更を防ぐ技術的な仕組み。
型安全な分岐のまとめ
状態を型安全に管理(Union Types)。
状態を固定して外部からの拡張を禁止(Sealed Classes)。
まとめ
freezedを使うと、Flutterで不変データモデルを簡単に作成できるだけでなく、付随する機能も活用して、安全で効率的な状態管理が実現できる。
Discussion