🧊

immutableなデータモデルの管理

2024/12/19に公開

はじめに

データモデルが変更可能(mutable)である場合、予期しない変更やデータの不整合が発生し、これがアプリケーションのバグの原因になることがある。一方で、immutableなデータモデルの管理を行えば一度作成されたデータが変更されないことで、安全性が保証され、状態管理がシンプルになる。

さらに、Flutterにおいては、状態管理のライブラリであるRiverpodが状態の変更を「新しい状態の作成」として扱うという点で、immutableなデータモデルとの相性が良い。

ここではfreezedを使ったimmutableなデータモデルを設計・管理する方法とFlutterアプリにおける実践方法を扱う。

freezedとは

  • Flutterでimmutableなデータモデルを作成するためのライブラリ
  • JSONシリアライズ、copyWithメソッド、型安全な分岐などもサポート

freezedでのimmutableなデータモデルの実現

1. 必要なパッケージをインストール

pubspec.yaml
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 で出力先を限定すると、それに対応する入力ファイルだけがスキャン対象となる。

  1. プロジェクトのルートディレクトリにbuild.yamlを作成
  2. 以下の内容を記述する
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'
  1. 入力ファイルの指定
    ^lib/model/{{}}.dart は、lib/model ディレクトリ配下のすべてのDartファイルを入力対象とする。例えば、lib/model ディレクトリにモデルが集中していれば、他のディレクトリ(例: lib/service や lib/view)は無視される。lib/model/todo/など適宜カスタマイズして実行するのも良い。
  2. 出力先を特定のディレクトリに指定
    生成ファイル(例: g.dart や freezed.dart)は lib/model/generate に保存される。
    入力ファイルごとに対応する生成ファイルが、このディレクトリにまとめられるようになる。

大変参考になりました

https://zenn.dev/satoru_inoue/articles/18c58f7c786269

3. データモデルの作成

例: lib/model/todo.dart にデータモデルを記述

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