[Xcode/Swift] iOSアーキテクチャ完全入門、有名どころの概念をざっくり理解する

1. はじめに

1.1 なぜアーキテクチャが必要?

  • 保守性:時間が経っても壊れにくい
  • テスト容易性:仕様を自動で守らせる
  • チーム開発:責務が明確=衝突が減る

本記事のゴール:

各アーキテクチャの向き不向きと考え方を掴んで、明日からの小さな実装に一歩取り入れられること。完璧主義は不要。まずは「どんな設計思想か」を自分の言葉で理解 & 説明できればOK。

1.2 今回扱うアーキテクチャ

  • MVC (Model / View / Controller)
  • MVVM (Model / View / ViewModel)
  • VIPER (View / Interactor / Presenter / Entity / Router)
  • Clean Architecture
  • TCA (The Composable Architecture)

2. 共通基礎:データフローと言葉の整理

  • State:画面に映る値(例:count = 0
  • Action(Event):ユーザーの操作や外部からの入力(例:ボタンタップ)
  • Effect(副作用):ネットワーク・DB・タイマーなど、外界とのやり取り
  • 依存関係:上位(UI)→下位(ドメイン)へ向けるほどテストしやすい

比喩:レストラン

  • 客=View、注文=Action、キッチン=ロジック、仕入れ=副作用
  • 忙しくなるほど「誰が何をするか」を分けた方が早くてミスが減る=アーキテクチャの存在意義

3. MVC(Model–View–Controller)

最短で動く伝統的スタイル。UIKit では自然、SwiftUI では MVVM に寄りやすい。

  • Model:データとビジネスルール
  • View:見た目(UIKit: UIView / SwiftUI: View
  • Controller:入力を受けて Model を更新し View を更新

※ 落とし穴:

  • あれもこれも Controller に詰め込み→Fat ViewControllerになりがち

3.1 例(UIKit)

// Model
struct Counter {
    var value = 0
}

// Controller
final class CounterViewController: UIViewController {
    private var counter = Counter()
    @IBOutlet private weak var label: UILabel!

    @IBAction private func plusTapped(_ sender: UIButton) {
        counter.value += 1
        label.text = "\(counter.value)"
    }
}

3.2 SwiftUI での注意

SwiftUI には ViewController がないため、ロジックをどこに置くかでMVVMへ自然に移行しがち。
小規模なら @State/@StateObject で十分。ただし複雑化したら次章の MVVM を検討。


4. MVVM(Model–View–ViewModel)

UI ロジックを ViewModel に寄せて View を薄くする。SwiftUI と相性◎。

  • ViewModel:表示用の状態と振る舞いを提供(ObservableObject
  • View@StateObject/@ObservedObject で ViewModel を監視
  • 効果:UI なしでも ViewModel をテストできる

※ 落とし穴:肥大化する ViewModel → 入出力を分ける、機能ごとに VM を分割 (RxSwift, Combineをうまく活用)

import SwiftUI

// Model、今回はシンプルなので省略
struct Counter {
    var value = 0
}

// ViewModel
final class CounterViewModel: ObservableObject {
    @Published private(set) var value = 0

    func increment() {
        value += 1
    }
    func decrement() {
        value -= 1
    }
}

// View
struct CounterView: View {
    @StateObject private var vm = CounterViewModel()

    var body: some View {
        VStack(spacing: 16) {
            Text("\(vm.value)").font(.largeTitle.monospacedDigit())
            HStack {
                Button("−") {
                    vm.decrement()
                }
                Button("+") {
                    vm.increment()
                }
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

5. Clean Architecture

ユースケース中心。**依存関係逆転(DIP)**で内側(ドメイン)を守る。

  • Entities:ビジネスルール(純粋な型)
  • UseCases(Interactor):アプリ固有の振る舞い
  • Interface Adapters:Presenter/ViewModel、Repository 実装
  • Frameworks:UI・DB・HTTP 等の外部
import SwiftUI
import Foundation

// Entity
struct Todo: Identifiable, Equatable {
    let id: UUID
    var title: String
    var done: Bool
}

// UseCase の境界
protocol AddTodoUseCase {
    func add(title: String) async throws -> Todo
}

// Repository の境界
protocol TodoRepository {
    func create(title: String) async throws -> Todo
}

// UseCase 実装(ドメイン層)
struct AddTodoInteractor: AddTodoUseCase {
    let repo: TodoRepository

    func add(title: String) async throws -> Todo {
        // ビジネスルールをここに
        guard !title.trimmingCharacters(in: .whitespaces).isEmpty else {
            throw ValidationError.emptyTitle
        }
        return try await repo.create(title: title)
    }
}

enum ValidationError: Error {
    case emptyTitle
}

// Infra 実装(データ層)
final class InMemoryTodoRepository: TodoRepository {
    private var storage: [Todo] = []
    func create(title: String) async throws -> Todo {
        let todo = Todo(id: UUID(), title: title, done: false)
        storage.append(todo)
        return todo
    }
}

// Presenter/ViewModel(UI 境界)
@MainActor
final class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    @Published var error: String?

    private let addTodo: AddTodoUseCase

    init(addTodo: AddTodoUseCase) {
        self.addTodo = addTodo
    }

    func add(title: String) {
        Task {
            do {
                let new = try await addTodo.add(title: title); todos.append(new)
            }
            catch {
                self.error = "\(error)"
            }
        }
    }
}

// SwiftUI View(Frameworks 層)
struct TodoView: View {
    @StateObject private var vm: TodoViewModel
    @State private var title = ""

    init() {
        let repo = InMemoryTodoRepository()
        let uc = AddTodoInteractor(repo: repo)
        _vm = StateObject(wrappedValue: TodoViewModel(addTodo: uc))
    }

    var body: some View {
        VStack {
            HStack {
                TextField("何する?", text: $title)
                Button("追加") { vm.add(title: title); title = "" }
            }
            List(vm.todos) { Text($0.title) }
        }
        .padding()
    }
}

ポイント:

  • 境界は Protocol で定義 → UI なしで UseCase をテストできる
  • 外部の事情(DB/HTTP)を内側に漏らさない

6. TCA(The Composable Architecture)

単方向データフローを厳密に表現し、**合成(Composable)**で大規模でも破綻しにくい。

  • State:画面の状態
  • Action:イベント
  • Reducer:State と Action を受けて次の State と Effect を返す
  • Store:状態と副作用のハブ
  • 合成:小さな Feature を組み合わせて大きな画面へ
import SwiftUI
import ComposableArchitecture

@Reducer
struct CounterFeature {
    struct State: Equatable { var value = 0 }
    enum Action: Equatable {
        case increment
        case decrement
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .increment:
                state.value += 1
                return .none
            case .decrement:
                state.value -= 1
                return .none
            }
        }
    }
}

struct CounterView: View {
    let store: StoreOf<CounterFeature>
    var body: some View {
        WithPerceptionTracking {
            let state = store.state
            VStack(spacing: 16) {
                Text("\(state.value)").font(.largeTitle.monospacedDigit())
                HStack {
                    Button("−") { store.send(.decrement) }
                    Button("+") { store.send(.increment) }
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        }
    }
}

// どこかのエントリポイント
// CounterView(store: Store(initialState: .init()) { CounterFeature() })

ポイント:

テストのしやすさ:Reducer は「入力(Action)→出力(State変化/Effect)」が明確で、ユニットテストが書きやすい。


7. VIPER

画面単位で責務を厳格に分離するパターン。特に**画面遷移の責務(Router)**が明確。

  • View:表示とユーザー入力の受け取り
  • Interactor:ユースケースの実行(ドメイン)
  • Presenter:表示用に整形・イベント中継(SwiftUI では ObservableObject 化しやすい)
  • Entity:ドメインのデータ
  • Router(Wireframe):画面遷移・組み立て
import SwiftUI

// MARK: - Entity
struct CounterEntity {
    var value = 0
}

// MARK: - Interactor
protocol CounterUseCase {
    func increment(_ entity: inout CounterEntity)
}
struct CounterInteractor: CounterUseCase {
    func increment(_ entity: inout CounterEntity) {
        entity.value += 1
    }
}

// MARK: - Router
protocol CounterRouterInput {
    func routeToResult(value: Int)
}
final class CounterRouter: CounterRouterInput, ObservableObject {
    @Published var isShowingResult = false
    var passedValue: Int = 0

    func routeToResult(value: Int) {
        passedValue = value
        isShowingResult = true
    }
}

// MARK: - Presenter
@MainActor
final class CounterPresenter: ObservableObject {
    @Published var displayText: String = "0"

    private var entity = CounterEntity()
    private let interactor: CounterUseCase
    private let router: CounterRouterInput

    init(interactor: CounterUseCase, router: CounterRouterInput) {
        self.interactor = interactor
        self.router = router
    }

    func didTapPlus() {
        interactor.increment(&entity)
        displayText = "\(entity.value)"
    }

    func didTapShowResult() {
        router.routeToResult(value: entity.value)
    }
}

// MARK: - View
struct CounterVIPERView: View {
    @StateObject private var router = CounterRouter()
    @StateObject private var presenter: CounterPresenter

    init() {
        let router = CounterRouter()
        _router = StateObject(wrappedValue: router)
        _presenter = StateObject(wrappedValue: CounterPresenter(
            interactor: CounterInteractor(),
            router: router
        ))
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                Text(presenter.displayText)
                    .font(.largeTitle.monospacedDigit())
                Button("+") {
                    presenter.didTapPlus()
                }
                Button("結果を見る") {
                    presenter.didTapShowResult()
                }

                NavigationLink(
                    destination: ResultView(value: router.passedValue),
                    isActive: $router.isShowingResult
                ) {
                    EmptyView()
                }
            }
            .padding()
        }
    }
}

// MARK: - 遷移先 View(Result)
struct ResultView: View {
    let value: Int
    var body: some View {
        VStack(spacing: 20) {
            Text("最終カウント")
            Text("\(value)")
                .font(.largeTitle.monospacedDigit())
        }
        .navigationTitle("結果")
    }
}

ポイント:

  • ファイル数が増える(テンプレ化・ジェネレータ活用で軽減)
  • 命名・責務がブレると逆効果 → **「入力は Presenter、ユースケースは Interactor、遷移は Router」**を徹底

8. 比較・選定ガイドと実践ステップ

8.1 ざっくり比較(◎:強い / △:普通 / ▲:弱い)

観点MVCMVVMVIPERCleanTCA
学習コスト
実装速度(小規模)
テスト容易性
規模適性(中〜大)
一貫したデータフロー

8.2 どれを選ぶ?

  • 個人・試作・小規模:まず MVVM(最小コストでテストもしやすい)
  • 中規模で機能が増える:MVVM に **Clean の考え(UseCase, Repository 境界)**を一部導入
  • 厳密な状態管理・長期運用TCA または Clean
  • 画面遷移と責務を厳格に分けたいVIPER

8.3 移行の道筋(無理なく段階的に)

  • MVC → View のロジックを ViewModel へ(MVVM)
  • UseCase/Repository の Protocol 境界を導入(Clean っぽく)
  • 状態管理の一貫性が必要になったら TCA へ、画面単位の厳格分業なら VIPER

8.4 テスト観点チェックリスト

  • UI 抜きでビジネスルールをテストできるか?
  • 依存(HTTP/DB)を差し替えられるか?(Protocol + DI)
  • 非同期副作用の結果を待たずにロジックを検証できるか?
  • 仕様変更時、触るファイルが限定されるか?

まとめ

正解は一つではないので、チームや規模に合わせて、責務の分離テスト容易性を少しずつ高めていけば十分。

まずは MVVM から始め、必要に応じて Clean/TCA/VIPER の要素を足していきましょう。


参考リンク