❄️

【Flutter】アプリ開発においてFreezedが想像以上に便利だった話

に公開

Freezedは、Flutter開発におけるデータクラスの自動生成をしてくれる代表的なパッケージであり、知っている人も多いと思います。
今回のアプリ開発ではインターバルタイマーを作成しました。
そのありとあらゆる場面でFreezedのデータクラス生成の恩恵を受けまくっていたことに気付いたため、この記事ではFreezedの魅力についてまとめます。

想定している読者像

  • Flutter開発を行っている
  • Freezedに興味がある

タイマーアプリ開発の動機

せっかくなので、インターバルタイマーを開発しようと思った経緯について簡単に紹介させていただきます。
大学や学会発表の場では、7分鈴、10分鈴、15分鈴を鳴動させるベルが必要だったりします。
この時に、定刻にベルを鳴らすシステムがスマホで用意できると便利だなぁと思い、いわゆるインターバルタイマーを開発してみたいと考えるようになりました。
そこで、Flutterでシンプルな操作感でありながら、柔軟な応用ができるインターバルタイマーを開発してみました。
https://play.google.com/store/apps/details?id=com.interval_timer

尚、この開発では、すべてのエンティティと値オブジェクトをFreezedによって作成しました。
この記事では、Freezedの使い方についても触れていきます。

Freezedとは

Freezedは、データクラス(エンティティとか値オブジェクト)に必要な機能を、コマンド一発で自動生成してくれるパッケージです。
https://pub.dev/packages/freezed

これの便利なところは、copyWith、toJson、fromJsonなどのデータクラスが持っておくべき機能を自分で書かずとも自動生成できるところです。
Freezedの自動生成によって利用できるようになる機能は次のようなものです。

  • equality
    • ==演算子で比較できるようになる。すべての属性の値が等しいエンティティを同一とみなす。
  • copyWith
    • 特定のパラメータだけ変更された新しいオブジェクトを作成する
  • toJson
    • オブジェクトをjson形式に変換する
  • fromJson
    • jsonからオブジェクトに変換する

データクラスであれば持っておくべき機能を人間の手で記述しなくて済むのです。
具体的な使い方については後述します。

Freezedの大まかな使い方

エンティティは、次に示すものを例として使います。
この例は、冒頭に紹介したインターバルタイマー開発の際に使用したものと近い構成となっています。

例として使用するエンティティ

  1. GroupTimer
    • 一つのGroupTimerに対し、一つ以上のMiniTimerを持つことができる。また、そのタイマーのループ回数を持っており、カスタムクラス(LoopCount)を使用する。
  2. MiniTimer
    • 逐次的に実行されるタイマー。一つ目のMiniTimerが終了したら、二つ目のMiniTimerが開始され、全てのMiniTimerが終了することで1ループとする。各MiniTimerは、タイマー時間と自身の順序を持っている。

これらを考慮すると、両者の関係性は次のように示せます。

実際のアプリに実装したコードはもっと複雑ですが、ここでのFlutterパッケージに関する説明においては、この程度の複雑さで十分です。

Freezed

先のER図に示した二つのエンティティ(GroupTimer, MiniTimer)と、値オブジェクトであるLoopCountをFreezedで再現すると、次のように書けます。

loop_count.dart
part 'session_loop_count.freezed.dart';
part 'session_loop_count.g.dart';

@freezed
class SessionLoopCount implements _$SessionLoopCount {
  const SessionLoopCount._();
  //簡単なアサーションであれば、以下のように宣言することで不正な値の入力を防止できる。
  @Assert('count >= 0', 'Order num numst be gather than 0.')
  const factory SessionLoopCount({
    // ignore: invalid_annotation_target
    @JsonKey(name: 'session_loop_count') required int count,
  }) = _SessionLoopCount;
}

//カスタムクラスを属性として持つFreezedクラスをtoJson、fromJsonに対応させる場合、
//該当するカスタムクラスのJsonConverterが必要となる。
class SessionLoopCountConverter
    implements JsonConverter<SessionLoopCount, String> {
  const SessionLoopCountConverter();

  @override
  SessionLoopCount fromJson(String text) {
    return SessionLoopCount.byText(text);
  }

  @override
  String toJson(SessionLoopCount count) {
    return count.getCountText();
  }
}
group_timer.dart
part 'timer_group_state.freezed.dart';
part 'timer_group_state.g.dart';

@freezed
class GroupTimer extends _$GroupTimer {
  const TimerGroupState._();
  // ignore_for_file: invalid_annotation_target
  @JsonSerializable(explicitToJson: true)
  const factory TimerGroupState({
    @JsonKey(name: 'timer_group_id') required String timerGroupId,
    @JsonKey(name: 'title') required String title,
    //自作したクラスのJsonConverterはここで宣言する。
    @SessionLoopCountConverter() required LoopCount loopCount,
  }) = _TimerGroupState;

  //これを追加すると、このエンティティのtoJsonとfromJsonが使える。
  factory TimerGroupState.fromJson(Map<String, dynamic> json) =>
    _$TimerGroupStateFromJson(json);
}
mini_timer.dart
part 'mini_timer_state.freezed.dart';
part 'mini_timer_state.g.dart';

@freezed
class MiniTimer extends _$MiniTimer{
  const MiniTimer._();
  // ignore_for_file: invalid_annotation_target
  @JsonSerializable(explicitToJson: true)
  const factory MiniTimer({
    @JsonKey(name: 'mini_timer_id') required String miniTimerId,
    @JsonKey(name: 'belonging_group_id') required String belongingGroupID,
    @JsonKey(name: 'duration') required Duration duration,
    @JsonKey(name: 'timer_order') required int timerOrder,
  }) = _MiniTimer;

  //これを追加すると、このエンティティのtoJsonとfromJsonが使える。
  factory MiniTimer.fromJson(Map<String, dynamic> json) =>
      _$MiniTimerFromJson(json);
}

書いている最中は赤線でいっぱいになると思いますが、すべて無視して一通り書ききってしまいましょう。書き終わったら、ターミナルに以下のコマンドを入力して実行します。

flutter pub run build_runner build --delete-conflicting-outputs

すると、ファイル名.g.dartと、ファイル名.freezed.dartの二種類のファイルがそれぞれ作成されると思います。
エラーが出てなければOKです。

正しく書いているのにエラーが出る時がある

この現象ですが、たまに陥ります。
先に示したコマンドを何度も実行していると、正しく記述されているにもかかわらずビルドエラーとなる現象です。その場合は、

flutter clean

を実行してみましょう。これに加えて、編集中のキャッシュも消しておくのがよいと思います。
VScodeを使用しているのであれば、「Ctrl + Shift + P」→「Celar Edit History」とやればキャッシュを削除できます。

その後、再度build_runnerコマンドを実行してみてください。
個人的な体感ですが、4~5割のエラーはこの方法で対処できます。

Freezed活躍の場面

Freezedによって作られたエンティティは、Riverpodによる状態の更新を行う場合や、Jsonによるデータの受け渡しなどを行う際に非常に重宝します。

例えば、Riverpodを使用する場合はcopyWithは特にありがたいと思いますし、他プラットフォームとの通信やデータベースとのやり取りを行うのであれば、toJsonとfromJsonは役に立つ場面が多いでしょう。
どちらにせよ、これらを自動的に生成してくれるのは非常にありがたいです。

まとめ

Freezedかなり便利なので、ぜひ使ってみてください。

Discussion