📱

freezed を使いこなしたメモ

2024/03/16に公開

Flutter で開発している際に、プッシュ通知のハンドリング部分で Freezed を使いこなしたので、そのメモです。
愚直な例から、ちょっとずつ freezed の機能を使いながらコードを進化させていきます。

サマリ

  • unionKey を指定して sealed class を実現
    • 必要なら unionValueCase を指定する
  • fallbackUnion を指定して、未知の形式に備える
  • カスタム JsonConverter を作って、プロパティを ValueObject として扱う
    • @Assert でプロパティの制約を表現する
  • @With@Implements を使ってサブクラス同士を抽象的に扱えるようにする

はじめに

freezed とは、dart のデータクラスを便利にしてくれるコード生成ツールです。
https://pub.dev/packages/freezed
dart の言語レベルでは提供されていない、copyWith 関数や、シリアライズ・デシリアライズ等の便利な機能を提供してくれます。

前提

freezed の基礎部分は理解しているものとします。
(公式サイトや各種記事を参照ください 🙇)

Firebase Cloud Messaging のハンドリング部分を想定します。
プッシュ通知の種別によって、送られてくるデータセットが異なります。
これをいかにスマートにアプリ内で定義したデータクラスに変換するか?という話です。

FirebaseMessaging.onMessage.listen((RemoteMessage remoteMessage) {
    final Map<String, dynamic> data = remoteMessage.data;
    // この data をどうやって良い感じにデータクラスに変換するか?
    // type によって処理を分岐したい
});

json の例

フォローされたときの通知
{
    "type": "followed",
    "personId" : "person123"
}
つながりリクエストされたときの通知
{
    "type": "requested",
    "personId" : "person123"
}
新着メッセージ通知
{
    "type": "new_message",
    "talkId": "talk123"
}
メッセージのリアクション通知
{
    "type": "reacted_message",
    "talkId": "talk123"
}

※ type はアプリの機能が増えたら、取り得る値も増える可能性があります
※ personId や talkId は type によって指定されたり指定されなかったりする
 (例:type が followed の場合、personId は必ず指定される)

愚直な例

何も考えなければ、次のようなコードになるでしょう。

定義

class PushData with _$PushData {
  const factory PushData({
    /// 取り得る値は followed, requested, new_message, reacted_message
    required String type,

    /// type が followed, requested のときは指定されている
    String? personId,

    /// type が new_message, reacted_message のときは指定されている
    String? talkId,
  }) = _PushData;

  factory PushData.fromJson(Map<String, dynamic> json) =>
      _$PushDataFromJson(json);
}
ハンドリング
FirebaseMessaging.onMessage.listen((RemoteMessage remoteMessage) {
  final pushData = PushData.fromJson(remoteMessage.data);
  switch (pushData.type) {
    case 'followed':
      // followed のときは person は必ず指定されているので強制アンラップ
      openPersonPage(pushData.personId!);
    case 'new_message':
      // followed のときは talkId は必ず指定されているので強制アンラップ
      openTalkPage(pushData.talkId!);
    ...
    default:
    // 何もしない
  }
});

問題点

  • type によって personId や talkId のプロパティが null だったり null じゃなかったりする
    • type の値を見て強制アンラップをするのが嫌だ
    • 使わないプロパティがクラス定義にあるのが嫌だ
    • コメントでルールを記しているが、ミスしそうで怖い(実行時エラーなので気付くのが遅くなる)
  • switch の網羅性がない
    • case の typo や書き忘れのリスク
    • 新しい値を追加したときにビルドエラーにならず、対応漏れのリスク

unionKey を使う

使い方

(unionKey: 'type')
class PushData with _$PushData {
  /// type が followed のときのサブクラス
  /// プロパティは personId が必ず指定されている( talkId は存在しない)
  const factory PushData.followed({required String personId}) = _Followed;
  ...
}

このままだと、

const factory PushData.new_message({required String talkId}) = _NewMessage;

のように定義しないといけないので、 new_message -> newMessage とするためには、unionValueCaseFreezedUnionCase.snake を指定すれば OK です。

- (unionKey: 'type')
+ (unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
class PushData with _$PushData {
  ...
-  const factory PushData.new_message({required String talkId}) = _NewMessage;
+  const factory PushData.newMessage({required String talkId}) = _NewMessage;
  ...
}

定義
(unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
class PushData with _$PushData {
  const factory PushData.followed({required String personId}) = _Followed;

  const factory PushData.requested({required String personId}) = _Requested;

  const factory PushData.newMessage({required String talkId}) = _NewMessage;

  const factory PushData.reactedMessage({required String talkId}) =
      _ReactedMessage;

  factory PushData.fromJson(Map<String, dynamic> json) =>
      _$PushDataFromJson(json);
}
ハンドリング
FirebaseMessaging.onMessage.listen((RemoteMessage remoteMessage) {
  final pushData = PushData.fromJson(remoteMessage.data);
  // 全パターンを map の引数に渡さないとビルドエラーになるので、書き忘れ防止になる
  pushData.map(
    followed: (followed) {
      // followed は null ではない personId を持つので、! が不要
      openPersonPage(followed.personId);
    },
    requested: (requested) {
      openPersonPage(requested.personId);
    },
    newMessage: (newMessage) {
      openTalkPage(newMessage.talkId);
    },
    reactedMessage: (reactedMessage) {
      openTalkPage(reactedMessage.talkId);
    },
  );
});

問題点

  • 未知の type の値が渡ってくると CheckedFromJsonException が throw される

fallbackUnion を使う

未知の type が渡ってきた場合に CheckedFromJsonException を throw させないためには、 fallbackUnion を指定することで回避できます。
旧バージョンのアプリで新しい type の値が渡ってきたときに、クラッシュさせてないために設定しておきます。
(サーバ側がアプリのバージョンを考慮して、古いアプリには新しい形式のデータを送らないという選択肢も採れるかと思います。)

使い方

(..., fallbackUnion: 'unknown')
class PushData with _$PushData {
  ...
  const factory PushData.unknown() = _Unknown;
  ...
}
定義
@Freezed(
  unionKey: 'type',
  unionValueCase: FreezedUnionCase.snake,
+  fallbackUnion: 'unknown',
)
class PushData with _$PushData {
  const factory PushData.followed({required String personId}) = _Followed;

  const factory PushData.requested({required String personId}) = _Requested;

  const factory PushData.newMessage({required String talkId}) = _NewMessage;

  const factory PushData.reactedMessage({required String talkId}) =
      _ReactedMessage;

+  const factory PushData.unknown() = _Unknown;

  factory PushData.fromJson(Map<String, dynamic> json) =>
      _$PushDataFromJson(json);
}
ハンドリング
FirebaseMessaging.onMessage.listen((RemoteMessage remoteMessage) {
  final pushData = PushData.fromJson(remoteMessage.data);
  pushData.map(
    followed: (followed) => openPersonPage(followed.personId),
    requested: (requested) => openPersonPage(requested.personId),
    newMessage: (newMessage) => openTalkPage(newMessage.talkId),
    reactedMessage: (reactedMessage) => openTalkPage(reactedMessage.talkId),
+   unknown: (value) { // 何もしない },
  );
});

カスタム JsonConverter を作って、プロパティを ValueObject として扱う

現状のコードの問題点

openPersonPage(...)openTalkPage(...) も引数に String を取るので、呼び間違える可能性が残っている。
例)

void openPersonPage(String personId) { ... }
void openTalkPage(String talkId) { ... }
...
pushData.map(
  // ビルドが通るが、実行時に不整合が起きる
  followed: (followed) => openTalkPage(followed.personId),
  ...
)

→ personId, talkId を ValueObject(それぞれ別の型) で扱う事で解決
また、 personId や talkId の制約(文字列長など)も定義できる

ValueObject の定義

class PersonId with _$PersonId {
  ('id.isNotEmpty', '') // id は空文字はダメ
  factory PersonId(String id) = _PersonId;
}

// TalkId も同様
Converter の定義
class PersonIdConverter implements JsonConverter<PersonId, String> {
  const PersonIdConverter();

  
  PersonId fromJson(String value) => PersonId(value);

  
  String toJson(PersonId personId) => personId.id;
}

// TalkIdConverter も同様
定義(抜粋)
@Freezed(
  unionKey: 'type',
  unionValueCase: FreezedUnionCase.snake,
  fallbackUnion: 'unknown',
)
class PushData with _$PushData {
  const factory PushData.followed({
    // String -> PersonId の変換
    () required PersonId personId
  }) = _Followed;

  ...

  const factory PushData.newMessage({
    // String -> TalkId の変換
    () required TalkId talkId
  }) = _NewMessage;
  ...
}

これにて、パラメータの取り違えを防止することができます。

void openPersonPage(PersonId personId) { ... }
void openTalkPage(TalkId talkId) { ... }
...
pushData.map(
  // 実行時エラーではなく、ビルドエラーになる
  // openTalkPage には TalkId 型を渡さなければいけないのに、 PersonId 型を渡している
  followed: (followed) => openTalkPage(followed.personId),
  ...
)

@With や @Implements を使ってサブクラス同士を抽象的に扱えるようにする

現状のコートでは _Followed_NewMessage などのサブクラスは、直接 PushData を継承していますが、サブクラス同士での抽象化はできていません。

例えば、_Followed_Requested は共に PersonId を持つので抽象化して同一視したい場合もあるでしょう。
そんなときには、 @With@Implements を使って、サブクラスに withimplements でのクラス指定が可能になります。

定義(抜粋)
abstract class HavingPeronId {
  PersonId get personId;
}

mixin HavingTalkId {
  TalkId get talkId;
}

class PushData with _$PushData {
  <HavingPersonId>() // _Followed implements HavingPersonId になる
  const factory PushData.followed({
    () required PersonId personId,
  }) = _Followed;

  ...

  <HavingTalkId>() // _NewMessage with HavingTalkId になる
  const factory PushData.newMessage({
    () required TalkId talkId,
  }) = _NewMessage;
  ...
}

Discussion