仕様との比較がしやすいモバイルアプリの複雑な画面の実装
こんにちは。エンジニアの竹沢です。
直近3年ほどは、モバイルアプリの開発に携わっています。
その経験の中で、共通して悩んだ実装の一つが
入力フォームが存在する複雑な画面の実装
です。
実装を終えて、仕様との比較をしながら動作確認をするときに、
コードと仕様の対応関係が追いづらく、仕様通りになっているのかが、直感的に分かりにくくなりがちでした。
sealed class と enum で UI State を定義し分岐を集約することで、対応関係を追いやすくできたので、その実装例を紹介します。
使用技術
- Flutter + Riverpod
- MVVM(に近い)アーキテクチャを想定
https://github.com/takezawa-asial/flutter_spec_driven_ui_sample/
複雑な画面の仕様の例
例として、以下のようなECアプリの注文フォーム画面仕様を(ChatGPTに手伝ってもらいながら)考えました。
ECアプリの注文フォーム画面の仕様
前提の語彙
- 注文タイプ:通常/定期/予約
- 支払い方法:クレジットカード/代金引換/銀行振込
- 配送:自宅配送/店舗受取
- 在庫:在庫あり/残りわずか/在庫なし
- クーポン:なし/%割引/定額割引
画面に求める状態
- 案内表示:注意・警告の表示有無と内容
- 支払いの選択可否:各支払い方法を選べるかどうか
- 支払い詳細:選択された方法に応じた追加入力の有無と項目
- 配送先入力:自宅配送か店舗受取かで必要な入力が変わる
- 購入ボタン:有効/無効(無効理由がある場合は文言も)
画面の仕様をケースごとに整理した表
| No | 注文タイプ | 在庫 | 配送 | クーポン | 支払いの選択可否 | 支払い詳細 | 案内表示 | 配送先入力 | 購入ボタン |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 通常 | 在庫あり | 自宅 | なし | すべて選択可 | 選択に応じた追加入力 (カード番号/銀行名・口座/不要) | なし | 住所入力 | 有効 |
| 2 | 通常 | 残りわずか | 自宅 | %割引 | 銀行振込は選択不可/他は可 | 選択に応じた追加入力 | 「在庫が残りわずか」 | 住所入力 | 有効 |
| 3 | 通常 | 在庫なし | 自宅 | なし | いずれも選択不可 | なし | 「在庫がありません」 | 住所入力 | 無効(理由:在庫なし) |
| 4 | 定期 | 在庫あり | 自宅 | なし | クレジットカードのみ選択可 | カード番号 | 「定期購入はクレジットカードのみ」 | 住所入力 | 有効 |
| 5 | 予約 | (予約扱い) | 店舗受取 | 定額割引 | 代金引換は選択不可/他は可 | 銀行名・口座 または カード番号 | 「予約商品は代引不可」 | 店舗コード入力 | 有効 |
| 6 | 通常 | 在庫あり | 店舗受取 | なし | すべて選択可 | 選択に応じた追加入力 | なし | 店舗コード入力 | 有効 |
| 7 | 予約 | (予約扱い) | 自宅 | %割引 | ※プロダクト方針により、カードのみ とする場合あり | カード番号 | 「予約商品は代引不可」等、方針に合わせて | 住所入力 | 有効 |
実装
ここでは、悪い実装例と良い実装例を以下で定義します。
良い、悪いは、環境や状況に応じて変わりますが、分かりやすさのために、この記事では以下で定義します。
悪い実装例: モデルのクラスのフィールドの値による条件分岐を、ウィジェット内で直接行っていて、仕様との対応関係が分かりにくい
良い実装例: 仕様に対応した形で条件分岐部分がまとまっている悪い実装例
以下のように、ウィジェット(View)内で直接ビジネス条件を判定してUIを分岐しています。
仕様との対応が散在し、追跡が難しくなります。
// Banner(在庫・予約・定期・在庫わずか)をView内で直接分岐
class _BadBanner extends StatelessWidget {
final Order order;
const _BadBanner({required this.order});
@override
Widget build(BuildContext context) {
String? banner;
final hasSoldOut = order.items.any((i) => i.stock == Stock.soldOut);
if (hasSoldOut && order.orderType != OrderType.preorder) {
banner = '在庫がありません';
} else if (order.orderType == OrderType.preorder) {
banner = '予約商品は代引不可';
} else if (order.orderType == OrderType.subscription) {
banner = '定期購入はクレジットカードのみ';
} else if (order.items.any((i) => i.stock == Stock.limited)) {
banner = '在庫が残りわずかです';
}
if (banner == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
),
child: Text(banner),
);
}
}
- View層に条件分岐が散在し、仕様の表との対応がわかりにくい
- 同じ分岐が複数箇所で記述されることがある
- 状態の組み合わせが増えるほど、漏れやすくなる
良い実装例
画面の仕様をケースごとに整理した表をそのまま「画面状態(sealed class + enum)」に写像し、Viewは状態をそのまま描画するだけに寄せます。
1) 画面状態(UI State)を定義(Freezedのsealed class)
@freezed
sealed class BannerState with _$BannerState {
const factory BannerState.none() = BannerNone;
const factory BannerState.info(String text) = BannerInfo;
}
// ...以下省略2) ドメイン(Order)からUI Stateへ変換(仕様の表をコードに落とす)
GoodOrderFormPageUiState deriveGoodUiState(Order o) {
// 1) 在庫なし(予約除く)
final hasSoldOut = o.items.any((i) => i.stock == Stock.soldOut);
if (hasSoldOut && o.orderType != OrderType.preorder) {
final allowed = (card: false, cod: false, bank: false);
final selected = coerceSelected(
o.paymentMethod,
card: allowed.card,
cod: allowed.cod,
bank: allowed.bank,
);
return GoodOrderFormPageUiState(
banner: const BannerState.info('在庫がありません'),
payment: PaymentUiState.options(
card: allowed.card,
cod: allowed.cod,
bank: allowed.bank,
selected: selected,
),
address: o.shipment == Shipment.home
? AddressSectionState.needHome(
postalCode: o.shippingAddress?.postalCode ?? '',
addressLine1: o.shippingAddress?.line1 ?? '',
)
: AddressSectionState.needPickup(
storeCode: o.pickupStore?.storeCode ?? '',
),
buy: const BuyButtonState.disabled('在庫なし'),
paymentDetail: detailFor(selected),
subtotal: o.totalBeforeDiscount,
discount: o.discountAmount,
total: o.totalAfterDiscount,
);
}
// 2) 予約:代引不可
if (o.orderType == OrderType.preorder) {
final allowed = (card: true, cod: false, bank: true);
final selected = coerceSelected(
o.paymentMethod,
card: allowed.card,
cod: allowed.cod,
bank: allowed.bank,
);
return GoodOrderFormPageUiState(
banner: const BannerState.info('予約商品は代引不可'),
payment: PaymentUiState.options(
card: allowed.card,
cod: allowed.cod,
bank: allowed.bank,
selected: selected,
),
address: o.shipment == Shipment.home
? AddressSectionState.needHome(
postalCode: o.shippingAddress?.postalCode ?? '',
addressLine1: o.shippingAddress?.line1 ?? '',
)
: AddressSectionState.needPickup(
storeCode: o.pickupStore?.storeCode ?? '',
),
buy: const BuyButtonState.enabled(),
paymentDetail: detailFor(selected),
subtotal: o.totalBeforeDiscount,
discount: o.discountAmount,
total: o.totalAfterDiscount,
);
}
// ...以下省略
}3) ViewはUI Stateをスイッチするだけ(分岐はViewから排除)
// Banner
class _GoodBannerView extends StatelessWidget {
final BannerState state;
const _GoodBannerView({required this.state});
@override
Widget build(BuildContext context) => switch (state) {
BannerNone() => const SizedBox.shrink(),
BannerInfo(:final text) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: const Color(0xFFFFF3CD), borderRadius: BorderRadius.circular(8)),
child: Text(text),
),
};
}- 条件分岐は「ドメイン → UIStateへの変換」に集約できる
- 画面の仕様をケースごとに整理した表との対応が
deriveGoodUiStateにまとまり、レビューや変更がわかりやすい - sealed classの網羅性チェックにより、分岐漏れが検出しやすい
- ドメイン -> UIState -> Viewと変換が1回増えてしまうのはデメリットと感じる
deriveGoodUiStateをどこで行うかは考える余地があると感じた(OrderFormStateにgetterを定義しても良いかもしれない)
最後に
この記事で紹介した実装は、注文入力フォームなど「ユーザーの入力値に応じてUIが変わる画面」で特に有効だと感じています。
一方で、処理や記述が増えてしまいます。
APIから情報を取得して表示するだけのような、簡単な画面ではここまで行うべきかは悩みどころです。
アーキテクチャの統一を優先するか、実装スピードを優先するかでも変わってくるかなと思います。
実装の一例として、参考になれば嬉しいです。読んでいただきありがとうございました。








