freezed を使いこなしたメモ
Flutter で開発している際に、プッシュ通知のハンドリング部分で Freezed を使いこなしたので、そのメモです。
愚直な例から、ちょっとずつ freezed の機能を使いながらコードを進化させていきます。
サマリ
-
unionKey
を指定して sealed class を実現- 必要なら
unionValueCase
を指定する
- 必要なら
-
fallbackUnion
を指定して、未知の形式に備える - カスタム JsonConverter を作って、プロパティを ValueObject として扱う
-
@Assert
でプロパティの制約を表現する
-
-
@With
や@Implements
を使ってサブクラス同士を抽象的に扱えるようにする
はじめに
freezed とは、dart のデータクラスを便利にしてくれるコード生成ツールです。
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
とするためには、unionValueCase
に FreezedUnionCase.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
を使って、サブクラスに with
や implements
でのクラス指定が可能になります。
定義(抜粋)
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