テクノロジー

仕様との比較がしやすいモバイルアプリの複雑な画面の実装

こんにちは。エンジニアの竹沢です。
直近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から情報を取得して表示するだけのような、簡単な画面ではここまで行うべきかは悩みどころです。

アーキテクチャの統一を優先するか、実装スピードを優先するかでも変わってくるかなと思います。
実装の一例として、参考になれば嬉しいです。読んでいただきありがとうございました。

前の記事へ

次の記事へ

一覧へ戻る

「テクノロジー」カテゴリの最新記事

PAGE TOP