ソフトウェア設計

SwiftのState Patternでif文地獄から脱出する

State Patternは、状態ごとの振る舞いと遷移をオブジェクトやenumにまとめ、分散した条件分岐を減らします。Swiftの画面状態をリファクタリングする手順と、Strategy Patternに似て見える構造を意図で見分ける方法を解説します。

読了 5 分
SwiftのState Patternでif文地獄から脱出するのカバー画像

画面の状態を1つ変えるだけなのに、if文を10個も修正した経験はありませんか?

ローディング・成功・失敗の状態をすべてif isLoadingelse if hasErrorで処理していると、コードはすぐに手に負えなくなります。

今回は、実体験をもとに、SwiftのState Patternでif文地獄を状態オブジェクトに整理する方法を紹介します。

要点から言うと、State Patternとは各状態を別々のオブジェクト(またはenum)に分け、状態遷移のロジックをそのオブジェクト内に置くことです。条件分岐が散らばらず、1か所に集約できます。

構造がほぼ同じStrategy Patternとの違いは、誰が変更を主導するかです。Strategy Pattern vs. State Patternで、外部からの選択と内部の状態遷移を先に区別できます。

この記事でわかること

  1. なぜif文地獄が起きるのか
  2. Swiftで状態をオブジェクトに分ける2つの方法
  3. enumとprotocolのどちらをいつ使うか
  4. 実務ですぐ使えるリファクタリング手順

なぜif文地獄は起きるのでしょうか?

最初は状態が2つほどしかないので、ifで十分に見えます。しかし要件が増えると、状態は5つ、6つへ膨らみます。ローディング、成功、失敗、空画面、再試行中……といった具合です。

問題は、こうした状態の組み合わせが複数のメソッドに分散することです。ボタンの有効化ロジックにもif、画面更新にもif、ネットワークコールバックにもifが繰り返し現れます。そのため状態を1つ追加すると、そのifの塊をすべて探して修正する必要があり、1つでも漏れるとすぐバグになります。

状態が散らばるとバグは隠れ、状態を集めるとバグが見えるようになります。

State Patternは、まさにこの「分散」を防ぐための構造です。


Swiftで状態をオブジェクトに分ける方法(enum方式)

最も軽く始められる方法はenumです。状態を値として定義し、関連値で必要なデータも保持できます。以下は画面状態をenumで表現した例です。

enum ViewState {
    case loading
    case loaded(items: [String]) // 成功時のデータを添付
    case failed(message: String) // 失敗理由を添付
    case empty
}

これで画面更新はswitch一度で済みます。関連値のおかげで、「成功なのにデータがnil」という曖昧な状態自体が生まれません。

switch state {
case .loading:      showSpinner()
case .loaded(let items): render(items)
case .failed(let msg):   showError(msg)
case .empty:        showEmptyView()
}

ほとんどの画面状態は、このenum方式で十分だと感じます。switchはケースの漏れをコンパイラが検出してくれるので、状態追加も安心です。

画面状態を1つのswitchにまとめるとコードがずっと扱いやすくなる
画面状態を1つのswitchにまとめるとコードがずっと扱いやすくなる

enum vs. protocol、いつ何を使う?

状態ごとの「振る舞い」が大きく異なり、遷移ルールが複雑ならprotocol方式が適しています。各状態を型として作り、次の状態を自ら返す構造です。2つの方式の違いをまとめると次のとおりです。

区分 enum方式 protocol方式
適した状況 状態がデータ中心 状態ごとに振る舞いが異なる
状態の追加 caseを追加 型を追加
遷移ロジック 外部でswitch 状態オブジェクト内にカプセル化
学習コスト 低い やや高い

protocol方式の要点は、状態遷移を各オブジェクトが担当することです。

protocol PlayerState {
    func play() -> PlayerState  // 次の状態を返す
    func pause() -> PlayerState
}

struct PlayingState: PlayerState {
    func play() -> PlayerState { self }        // 再生中ならそのまま維持
    func pause() -> PlayerState { PausedState() } // 一時停止へ遷移
}

このようにすると、「再生中にpauseを押したら?」というルールは、その状態の中だけに存在します。分岐は消え、各状態は自分のルールだけを知ればよくなります。

状態自身が次の状態を決めるprotocol方式の遷移
状態自身が次の状態を決めるprotocol方式の遷移

実務でのリファクタリングはこの順番で

一度にすべて変えようとすると、かえって不安になります。私は次の順番で少しずつ移行しました。

  1. 散らばったifが実際に表している状態の一覧を紙に書く
  2. その状態を1つのenumとして定義する
  3. まず画面更新コードをswitchに置き換える
  4. ローディングとエラーが同時に起きるなど、不可能な状態の組み合わせをenumで取り除く
  5. 遷移ルールが複雑な部分だけをprotocolへ昇格する

ポイントは、enumから始め、必要なときだけprotocolへ昇格することです。最初からprotocolで過度に設計すると、かえってコードが重くなります。

このように状態を描いてからenumへ移すと、ずっと簡単になる
このように状態を描いてからenumへ移すと、ずっと簡単になる

よくある質問(Q&A)

Q. 状態が3つしかなくてもパターンを使うべきですか?

必ずしも必要ではありません。ただし状態が複数のメソッドに分散しているなら、数に関係なくenumにまとめるのがおすすめです。

Q. SwiftUIでも使えますか?

はい。状態を@Published var state: ViewStateの形で持ち、ビューでswitchによって分岐すると非常によく合います。

Q. enumの関連値が増えると複雑になりませんか?

関連値が3つを超えたら、別のstructにまとめてcase loaded(Result)のように保持することをおすすめします。


最初は慣れなくても、状態をオブジェクトにまとめる感覚をつかむと、以前のコードには戻りたくなくなるはずです。まずは小さな画面1つからenumで整理してみてください。思ったより早くif文地獄から抜け出せます。応援しています!

あわせて読みたい