mcpField
A
axiom-swiftui-architecture

axiom-swiftui-architecture

github.com/charleswiltgen開発1.2k273 回インストール今日更新
リポジトリ

axiom-swiftui-architecture について

ロジックの配置やテスト可能なアプリの構成を検討する開発者向けの SwiftUI アーキテクチャリファレンスです。Apple 標準パターン、MVVM、TCA、Coordinator を比較し、リファクタリングも解説します。

主な機能

  1. 01アーキテクチャ選択の判断ツリー
  2. 02Apple 標準の @Observable パターン
  3. 03MVVM と TCA の比較指針
  4. 04Coordinator によるナビゲーションとディープリンク
  5. 05ビュー内ロジックのリファクタリング手順

活用シーン

  1. 01新しい SwiftUI アプリのパターン選択
  2. 02ビューからビジネスロジックを抽出
  3. 03ナビゲーションロジックのテスト可能化
  4. 04チーム開発向けのプレゼンテーションロジック整理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-architecture

インストール後、Claude Code で/axiom-swiftui-architectureを入力して読み込まれたか確認します。

詳細

SwiftUI アーキテクチャ

このスキルを使用する場面

次のような場合にこのスキルを使用してください:

  • SwiftUI のビュー ファイルにあるロジックを切り出したい
  • MVVM、TCA、標準の SwiftUI パターン、Coordinator のどれを選ぶか検討している
  • 関心事を分離するためにビューをリファクタリングしている
  • SwiftUI コードをテスト可能にしたい
  • 「このコードはどこに置けばよいか」と悩んでいる
  • どのプロパティラッパーを使うか判断している(@State、@Environment、@Bindable)
  • チームで開発するために SwiftUI のコードベースを整理している

プロンプトの例

質問の例このスキルが役立つ理由
「モデルビューのファイルにロジック関連のコードがかなりあります。どう切り出せばよいですか?」ロジックの配置先を判断する決定木を含むリファクタリングのワークフローを提示します
「MVVM、TCA、Apple の標準パターンのどれを使うべきですか?」アプリの複雑さ、チームの規模、テスト容易性の要件に基づいた判断基準を示します
「SwiftUI コードをテスト可能にするにはどうすればよいですか?」SwiftUI のインポートなしでテストできる分離パターンを紹介します
「フォーマッターや計算処理はどこに置けばよいですか?」アンチパターンのセクションで、ビューの body 内にロジックを置くことを防ぎます
「どのプロパティラッパーを使えばよいですか?」@State、@Environment、@Bindable、または通常のプロパティを選ぶための決定木を示します

アーキテクチャのクイック決定木

What's driving your architecture choice?
│
├─ Starting fresh, small/medium app, want Apple's patterns?
│  └─ Use Apple's Native Patterns (Part 1)
│     - @Observable models for business logic
│     - State-as-Bridge for async boundaries
│     - Property wrapper decision tree
│
├─ Familiar with MVVM from UIKit?
│  └─ Use MVVM Pattern (Part 2)
│     - ViewModels as presentation adapters
│     - Clear View/ViewModel/Model separation
│     - Works well with @Observable
│
├─ Complex app, need rigorous testability, team consistency?
│  └─ Consider TCA (Part 3)
│     - State/Action/Reducer/Store architecture
│     - Excellent testing story
│     - Learning curve + boilerplate trade-off
│
└─ Complex navigation, deep linking, multiple entry points?
   └─ Add Coordinator Pattern (Part 4)
      - Can combine with any of the above
      - Extracts navigation logic from views
      - NavigationPath + Coordinator objects

パート 1:Apple のネイティブパターン(iOS 26+)

基本原則

「データモデルは、データと、そのデータを操作するビューを分離します。この分離によってモジュール性が高まり、テスト容易性が向上し、アプリの仕組みを理解しやすくなります。」 — Apple Developer Documentation

Apple の最新の SwiftUI パターン(WWDC 2023-2025)の中心となるのは次のとおりです:

  1. データモデルに @Observable を使用する(ObservableObject に代わるもの)
  2. 非同期処理の境界に State-as-Bridge を使用する(WWDC 2025)
  3. 3 つのプロパティラッパー:@State、@Environment、@Bindable
  4. アニメーションのために UI 更新を同期的に行う

State-as-Bridge パターン

問題

非同期関数が中断ポイントを作り、アニメーションを妨げることがあります:

// ❌ Problematic: Animation might miss frame deadline
struct ColorExtractorView: View {
    @State private var isLoading = false

    var body: some View {
        Button("Extract Colors") {
            Task {
                isLoading = true  // Synchronous ✅
                await extractColors()  // ⚠️ Suspension point!
                isLoading = false  // ❌ Might happen too late
            }
        }
        .scaleEffect(isLoading ? 1.5 : 1.0)  // ⚠️ Animation timing uncertain
    }
}
解決策:State をブリッジとして使用する

「時間に敏感な変更が必要な UI コードと、長時間実行される非同期ロジックとの境界を見つけます。」

// ✅ Correct: State bridges UI and async code
@Observable
class ColorExtractor {
    var isLoading = false
    var colors: [Color] = []

    func extract(from image: UIImage) async {
        // This method is async and can live in the model
        let extracted = await heavyComputation(image)
        // Synchronous mutation for UI update
        self.colors = extracted
    }
}

struct ColorExtractorView: View {
    let extractor: ColorExtractor

    var body: some View {
        Button("Extract Colors") {
            // Synchronous state change for animation
            withAnimation {
                extractor.isLoading = true
            }

            // Launch async work
            Task {
                await extractor.extract(from: currentImage)

                // Synchronous state change for animation
                withAnimation {
                    extractor.isLoading = false
                }
            }
        }
        .scaleEffect(extractor.isLoading ? 1.5 : 1.0)
    }
}

メリット:

  • UI ロジックが同期的に保たれる(アニメーションが正しく動作します)
  • 非同期コードがモデル内に置かれる(SwiftUI なしでテストできます)
  • 時間に敏感な UI と長時間実行される処理との境界が明確になります

プロパティラッパーの決定木

答えるべき質問は 3 つだけです:

Which property wrapper should I use?
│
├─ Does this model need to be STATE OF THE VIEW ITSELF?
│  └─ YES → Use @State
│     Examples: Form inputs, local toggles, sheet presentations
│     Lifetime: Managed by the view's lifetime
│
├─ Does this model need to be part of the GLOBAL ENVIRONMENT?
│  └─ YES → Use @Environment
│     Examples: User account, app settings, dependency injection
│     Lifetime: Lives at app/scene level
│
├─ Does this model JUST NEED BINDINGS?
│  └─ YES → Use @Bindable
│     Examples: Editing a model passed from parent
│     Lightweight: Only enables $ syntax for bindings
│
└─ NONE OF THE ABOVE?
   └─ Use as plain property
      Examples: Immutable data, parent-owned models
      No wrapper needed: @Observable handles observation
例
// ✅ @State — View owns the model
struct DonutEditor: View {
    @State private var donutToAdd = Donut()  // View's own state

    var body: some View {
        TextField("Name", text: $donutToAdd.name)
    }
}

// ✅ @Environment — App-wide model
struct MenuView: View {
    @Environment(Account.self) private var account  // Global

    var body: some View {
        Text("Welcome, \(account.userName)")
    }
}

// ✅ @Bindable — Need bindings to parent-owned model
struct DonutRow: View {
    @Bindable var donut: Donut  // Parent owns it

    var body: some View {
        TextField("Name", text: $donut.name)  // Need binding
    }
}

// ✅ Plain property — Just reading
struct DonutRow: View {
    let donut: Donut  // Parent owns, no binding needed

    var body: some View {
        Text(donut.name)  // Just reading
    }
}

@Observable モデルパターン

UI の更新をトリガーする必要があるビジネスロジックには @Observable を使用します:

// ✅ Domain model with business logic
@Observable
class FoodTruckModel {
    var orders: [Order] = []
    var donuts = Donut.all

    var orderCount: Int {
        orders.count  // Computed properties work automatically
    }

    func addDonut() {
        donuts.append(Donut())
    }
}

// ✅ View automatically tracks accessed properties
struct DonutMenu: View {
    let model: FoodTruckModel  // No wrapper needed!

    var body: some View {
        List {
            Section("Donuts") {
                ForEach(model.donuts) { donut in
                    Text(donut.name)  // Tracks model.donuts
                }
                Button("Add") {
                    model.addDonut()
                }
            }
            Section("Orders") {
                Text("Count: \(model.orderCount)")  // Tracks model.orders
            }
        }
    }
}

仕組み(WWDC 2023/10149):

  • SwiftUI は body の実行中にアクセスされたプロパティを追跡します
  • 変更されたときにビューの更新をトリガーするのは、それらのプロパティだけです
  • 依存関係を細かく追跡することで、パフォーマンスが向上します

ViewModel アダプターパターン

フィルタリング、並べ替え、またはビュー固有のロジックが必要な場合は、ViewModel をプレゼンテーションアダプターとして使用します:

// ✅ ViewModel as presentation adapter
@Observable
class PetStoreViewModel {
    let petStore: PetStore  // Domain model
    var searchText: String = ""

    // View-specific computed property
    var filteredPets: [Pet] {
        guard !searchText.isEmpty else { return petStore.myPets }
        return petStore.myPets.filter { $0.name.contains(searchText) }
    }
}

struct PetListView: View {
    @Bindable var viewModel: PetStoreViewModel

    var body: some View {
        List {
            ForEach(viewModel.filteredPets) { pet in
                PetRowView(pet: pet)
            }
        }
        .searchable(text: $viewModel.searchText)
    }
}

ViewModel アダプターを使用する場面:

  • 表示用のフィルタリング、並べ替え、グループ化
  • プレゼンテーション用のフォーマット(ただし、負荷の高い計算は行いません)
  • ドメインモデルに属さない、ビュー固有の状態
  • ドメインモデルと SwiftUI の慣例との橋渡し

ViewModel を使用しない場面:

  • モデルのデータを表示するだけのシンプルなビュー
  • ドメインモデルに属するロジック
  • 「パターンの純粋さ」だけを理由とした過剰な切り出し

パート 2:MVVM パターン

MVVM を使用する場面

次のような場合、MVVM(Model-View-ViewModel)が適しています:

✅ UIKit で使い慣れている — チームが導入しやすくなります
✅ View と ViewModel を明確に分離したい — 契約が明確になります
✅ プレゼンテーションロジックが複雑 — 複数のフィルタリングや並べ替え処理があります
✅ UIKit から移行している — なじみのある考え方を使えます

❌ 次の場合は MVVM を避けてください:

  • ビューがシンプル(データを表示するだけ)
  • SwiftUI で新規に始める(Apple のパターンのほうがシンプルです)
  • 不要な抽象化レイヤーを作ることになる

SwiftUI における MVVM の構成

// Model — Domain data and business logic
struct Pet: Identifiable {
    let id: UUID
    var name: String
    var kind: Kind
    var trick: String
    var hasAward: Bool = false

    mutating func giveAward() {
        hasAward = true
    }
}

// ViewModel — Presentation logic
@Observable
class PetListViewModel {
    private let petStore: PetStore

    var pets: [Pet] { petStore.myPets }
    var searchText: String = ""
    var selectedSort: SortOption = .name

    var filteredSortedPets: [Pet] {
        let filtered = pets.filter { pet in
            searchText.isEmpty || pet.name.contains(searchText)
        }
        return filtered.sorted { lhs, rhs in
            switch selectedSort {
            case .name: lhs.name < rhs.name
            case .kind: lhs.kind.rawValue < rhs.kind.rawValue
            }
        }
    }

    init(petStore: PetStore) {
        self.petStore = petStore
    }

    func awardPet(_ pet: Pet) {
        petStore.awardPet(pet.id)
    }
}

// View — UI only
struct PetListView: View {
    @Bindable var viewModel: PetListViewModel

    var body: some View {
        List {
            ForEach(viewModel.filteredSortedPets) { pet in
                PetRow(pet: pet) {
                    viewModel.awardPet(pet)
                }
            }
        }
        .searchable(text: $viewModel.searchText)
    }
}

SwiftUI における MVVM のよくある誤り

❌ 間違い 1:View と ViewModel で @Observable を重複させる
// ❌ @State + @Observable is redundant — @State creates its own storage
struct MyView: View {
    @State private var viewModel = MyViewModel()  // ❌ Redundant wrapper
}

// ✅ Pass @Observable directly — use @State only if the view OWNS the lifecycle
struct MyView: View {
    let viewModel: MyViewModel  // ✅ Or @State if view creates it
}
❌ 間違い 2:巨大な ViewModel
// ❌ Don't do this
@Observable
class AppViewModel {
    // Settings
    var isDarkMode = false
    var notificationsEnabled = true

    // User
    var userName = ""
    var userEmail = ""

    // Content
    var posts: [Post] = []
    var comments: [Comment] = []

    // ... 50 more properties
}
// ✅ Correct: Separate concerns
@Observable
class SettingsViewModel {
    var isDarkMode = false
    var notificationsEnabled = true
}

@Observable
class UserProfileViewModel {
    var user: User
}

@Observable
class FeedViewModel {
    var posts: [Post] = []
}
❌ 間違い 3:ViewModel にビジネスロジックを記述する
// ❌ Business rules belong in the Model, not the ViewModel
@Observable class OrderViewModel {
    func calculateDiscount(for order: Order) -> Double { /* ... */ }  // ❌ Business logic
}

// ✅ Model owns business logic; ViewModel only formats for display
struct Order {
    func calculateDiscount() -> Double { /* business rules */ }
}
@Observable class OrderViewModel {
    let order: Order
    var displayDiscount: String {
        "$\(order.calculateDiscount(), specifier: "%.2f")"  // ✅ Just formatting
    }
}

パート 3:TCA(Composable Architecture)

TCA を検討するタイミング

TCA は Point-Free によるサードパーティ製のアーキテクチャです。次の場合に検討してください。

✅ 厳密なテスト容易性が重要 — TestStore により、テストを決定論的に実行できます
✅ 大規模なチームで一貫性が必要 — 厳格なパターンにより、ばらつきを抑えられます
✅ 複雑な状態管理 — 副作用、依存関係、コンポジション
✅ Redux に似たパターンを重視する — 単方向のデータフロー

❌ 次の場合は TCA を避けてください:

  • 小規模なアプリやプロトタイプ(オーバーヘッドが大きすぎます)
  • チームが関数型プログラミングに不慣れ
  • 迅速な反復が必要(定型コードにより開発が遅くなります)
  • 依存関係を最小限にしたい

TCA の主要な概念

TCA には 4 つの構成要素があります。State(データ)、Action(イベント)、Reducer(状態の変化)、Store(実行時のエンジン)です。単一の機能での使用例を以下に示します。

@Reducer
struct CounterFeature {
    // STATE — Data your feature needs
    @ObservableState
    struct State {
        var count = 0
        var fact: String?
        var isLoading = false
    }

    // ACTION — All possible events
    enum Action {
        case incrementButtonTapped
        case decrementButtonTapped
        case factButtonTapped
        case factResponse(String)
    }

    // REDUCER — How state evolves in response to actions
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .incrementButtonTapped:
                state.count += 1
                return .none
            case .decrementButtonTapped:
                state.count -= 1
                return .none
            case .factButtonTapped:
                state.isLoading = true
                return .run { [count = state.count] send in
                    let fact = try await numberFact(count)
                    await send(.factResponse(fact))
                }
            case let .factResponse(fact):
                state.isLoading = false
                state.fact = fact
                return .none
            }
        }
    }
}

// STORE — Runtime that receives actions and executes reducer
struct CounterView: View {
    let store: StoreOf<CounterFeature>

    var body: some View {
        VStack {
            Text("\(store.count)")
            Button("Increment") { store.send(.incrementButtonTapped) }
        }
    }
}

TCA のトレードオフ

✅ メリット
メリット説明
テスト容易性TestStore により、テストを決定論的かつ網羅的に実行できます
一貫性すべての機能で同じパターンを使うことで、認知負荷を軽減できます
コンポジション小さな Reducer を組み合わせて、より大きな機能を構成できます
副作用構造化された効果管理(ネットワーク通信、タイマーなど)
❌ コスト
コスト説明
定型コード機能ごとに State/Action/Reducer が必要です
学習曲線関数型プログラミングの概念(効果、依存関係)が必要です
依存関係Apple がサポートしていないサードパーティ製ライブラリです
反復速度単純な機能でも、より多くのコードを書く必要があります

Apple のパターンより TCA を選ぶ場合

シナリオ推奨
小規模なアプリ(画面数 10 未満)Apple のパターン(よりシンプル)
中規模のアプリ、経験豊富なチームテスト容易性を優先するなら TCA
大規模なアプリ、複数のチーム一貫性を確保するため TCA
迅速なプロトタイピングApple のパターン(より高速)
ミッションクリティカル(銀行、医療)厳密なテストのため TCA

パート 4:Coordinator パターン

Coordinator を使う場面

Coordinator はナビゲーションロジックをビューから切り離します。次の場合に使用してください。

✅ 複雑なナビゲーション — 複数の経路、条件付きフロー
✅ ディープリンク — URL による、任意の画面へのナビゲーション
✅ 複数のエントリーポイント — 異なるコンテキストから同じ画面に遷移
✅ テスト可能なナビゲーション — ナビゲーションを UI から分離

SwiftUI での Coordinator の実装

// Minimal coordinator — Route enum + @Observable coordinator + NavigationStack binding
enum Route: Hashable {
    case detail(Pet)
    case settings
}

@Observable
class AppCoordinator {
    var path: [Route] = []

    func showDetail(for pet: Pet) { path.append(.detail(pet)) }
    func popToRoot() { path.removeAll() }
}

// Root view binds NavigationStack to coordinator's path
struct AppView: View {
    @State private var coordinator = AppCoordinator()

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            PetListView(coordinator: coordinator)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator)
                    case .settings: SettingsView(coordinator: coordinator)
                    }
                }
        }
    }
}

.onOpenURL と URL からルートへの解析を使って、ディープリンクを追加します。

// Add to AppCoordinator
func handleDeepLink(_ url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
    // Parse URL path into routes
    if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last {
        path = [.detail(loadPet(id: String(id)))]
    }
}

// Add to AppView's body
.onOpenURL { url in coordinator.handleDeepLink(url) }

Coordinator は SwiftUI を使わずにテストできます。パスの状態を直接検証します。

func testDeepLink() {
    let coordinator = AppCoordinator()
    coordinator.handleDeepLink(URL(string: "myapp://pets/123")!)
    XCTAssertEqual(coordinator.path.count, 1)  // Navigated to detail
}

状態復元、高度な URL ルーティング、タブベースの調整については、構成には axiom-swiftui-nav のパターン 7(Coordinator)、URL ベースのディープリンクにはパターン 1b を参照してください。

Coordinator とアーキテクチャの組み合わせ

Coordinator はどのアーキテクチャとも組み合わせられます。

パターンCoordinator の役割
Apple NativeCoordinator がパスを管理し、@Observable モデルがデータを管理
MVVMCoordinator がパスを管理し、ViewModel がプレゼンテーションを管理
TCACoordinator がパスを管理し、Reducer が機能を管理

パート 5:リファクタリングのワークフロー

ステップ 1:ビュー内のロジックを特定する

ビューに対して、次のチェックリストを実行します。

View の body に含まれるもの:

  • DateFormatter、NumberFormatter の生成
  • 計算またはデータ変換
  • API 呼び出しまたは非同期処理
  • ビジネスルール(割引、検証など)
  • データのフィルタリングまたはソート
  • 負荷の高い文字列操作
  • 複雑なロジックを含む Task { }

これらが 1 つでも含まれている場合、そのロジックは別の場所に移すべきです。

ステップ 2:適切なレイヤーに切り出す

次の判断ツリーを使用します。

Where does this logic belong?
│
├─ Pure domain logic (discounts, validation, business rules)?
│  └─ Extract to Model
│     Example: Order.calculateDiscount()
│
├─ Presentation logic (filtering, sorting, formatting)?
│  └─ Extract to ViewModel or computed property
│     Example: filteredItems, displayPrice
│
├─ External side effects (API, database, file system)?
│  └─ Extract to Service
│     Example: APIClient, DatabaseManager
│
└─ Just expensive computation?
   └─ Cache with @State or create once
      Example: let formatter = DateFormatter()
例:ビューからロジックをリファクタリングする
// ❌ Before: Logic in view body
struct OrderListView: View {
    let orders: [Order]

    var body: some View {
        let formatter = NumberFormatter()  // ❌ Created every render
        formatter.numberStyle = .currency

        let discounted = orders.filter { order in  // ❌ Computed every render
            let discount = order.total * 0.1  // ❌ Business logic in view
            return discount > 10.0
        }

        return List(discounted) { order in
            Text(formatter.string(from: order.total)!)  // ❌ Force unwrap
        }
    }
}
// ✅ After: Logic extracted

// Model — Business logic
struct Order {
    let id: UUID
    let total: Decimal

    var discount: Decimal {
        total * 0.1
    }

    var qualifiesForDiscount: Bool {
        discount > 10.0
    }
}

// ViewModel — Presentation logic
@Observable
class OrderListViewModel {
    let orders: [Order]
    private let formatter: NumberFormatter  // ✅ Created once

    var discountedOrders: [Order] {  // ✅ Computed property
        orders.filter { $0.qualifiesForDiscount }
    }

    init(orders: [Order]) {
        self.orders = orders
        self.formatter = NumberFormatter()
        formatter.numberStyle = .currency
    }

    func formattedTotal(_ order: Order) -> String {
        formatter.string(from: order.total as NSNumber) ?? "$0.00"
    }
}

// View — UI only
struct OrderListView: View {
    let viewModel: OrderListViewModel

    var body: some View {
        List(viewModel.discountedOrders) { order in
            Text(viewModel.formattedTotal(order))
        }
    }
}

ステップ 3:テスト容易性を確認する

次の条件を満たしていれば、リファクタリングは成功です。

// ✅ Can test without importing SwiftUI
import XCTest

final class OrderTests: XCTestCase {
    func testDiscountCalculation() {
        let order = Order(id: UUID(), total: 100)
        XCTAssertEqual(order.discount, 10)
    }

    func testQualifiesForDiscount() {
        let order = Order(id: UUID(), total: 100)
        XCTAssertTrue(order.qualifiesForDiscount)
    }
}

final class OrderViewModelTests: XCTestCase {
    func testFilteredOrders() {
        let orders = [
            Order(id: UUID(), total: 50),   // Discount: 5 ❌
            Order(id: UUID(), total: 200),  // Discount: 20 ✅
        ]
        let viewModel = OrderListViewModel(orders: orders)

        XCTAssertEqual(viewModel.discountedOrders.count, 1)
    }
}

Step 4: ビューのバインディングを更新する

抽出後、プロパティラッパーを更新します。

// Before refactoring
struct OrderListView: View {
    @State private var orders: [Order] = []  // View owned
    // ... logic in body
}

// After refactoring
struct OrderListView: View {
    @State private var viewModel: OrderListViewModel  // View owns ViewModel

    init(orders: [Order]) {
        _viewModel = State(initialValue: OrderListViewModel(orders: orders))
    }
}

// Or if parent owns it
struct OrderListView: View {
    let viewModel: OrderListViewModel  // Parent owns, just reading
}

// Or if need bindings
struct OrderListView: View {
    @Bindable var viewModel: OrderListViewModel  // Parent owns, need $
}

アンチパターン(絶対にしないでください)

❌ アンチパターン 1:ビュー本体にロジックを書く

// ❌ Don't do this
struct ProductListView: View {
    let products: [Product]

    var body: some View {
        let formatter = NumberFormatter()  // ❌ Created every render!
        formatter.numberStyle = .currency

        let sorted = products.sorted { $0.price > $1.price }  // ❌ Sorted every render!

        return List(sorted) { product in
            Text("\(product.name): \(formatter.string(from: product.price)!)")
        }
    }
}

問題点:

  • formatter がレンダリングのたびに作成されます(パフォーマンス)
  • sorted がレンダリングのたびに計算されます(パフォーマンス)
  • ビジネスロジック(sorted)がビューに置かれています(テストできません)
  • 強制アンラップ('!')によってクラッシュする可能性があります
// ✅ Correct
@Observable
class ProductListViewModel {
    let products: [Product]
    private let formatter = NumberFormatter()

    var sortedProducts: [Product] {
        products.sorted { $0.price > $1.price }
    }

    init(products: [Product]) {
        self.products = products
        formatter.numberStyle = .currency
    }

    func formattedPrice(_ product: Product) -> String {
        formatter.string(from: product.price as NSNumber) ?? "$0.00"
    }
}

struct ProductListView: View {
    let viewModel: ProductListViewModel

    var body: some View {
        List(viewModel.sortedProducts) { product in
            Text("\(product.name): \(viewModel.formattedPrice(product))")
        }
    }
}

❌ アンチパターン 2:境界のない非同期コード

上記のパート 1 にある State-as-Bridge パターンを参照してください。UI の状態変更は(withAnimation 内で)同期的に行い、非同期処理は別途 Task で開始します。

❌ アンチパターン 3:誤ったプロパティラッパー

// ❌ Don't use @State for passed-in models
struct DetailView: View {
    @State var item: Item  // ❌ Creates a copy, loses parent changes
}

// ✅ Correct: No wrapper for passed-in models
struct DetailView: View {
    let item: Item  // ✅ Or @Bindable if you need $item
}
// ❌ Don't use @Environment for view-local state
struct FormView: View {
    @Environment(FormData.self) var formData  // ❌ Overkill for local form
}

// ✅ Correct: @State for view-local
struct FormView: View {
    @State private var formData = FormData()  // ✅ View owns it
}

❌ アンチパターン 4:巨大な ViewModel

上記のパート 2 にある MVVM の間違い 2 を参照してください。関心ごとごとに分割し、別々の ViewModel にします。

❌ アンチパターン 5:@Observable 内での @AppStorage

@Observable クラス内で @AppStorage を使用しないでください。監視が気づかれないまま壊れます。@AppStorage は SwiftUI ビュー向けに設計されたプロパティラッパーであり、モデルクラス向けではありません。

// ❌ BROKEN — @AppStorage silently breaks @Observable
@Observable
class Settings {
    @AppStorage("theme") var theme = "light"  // Changes won't trigger view updates
}

// ✅ Read @AppStorage in view, pass to model
struct SettingsView: View {
    @AppStorage("theme") private var theme = "light"
    // ...
}

❌ アンチパターン 6:ビュー本体での Binding(get:set:)

ビュー本体で Binding(get:set:) を作成すると、評価のたびに新しいバインディングが作成され、SwiftUI のアイデンティティ追跡が壊れます。

// ❌ New Binding created every body evaluation
var body: some View {
    TextField("Name", text: Binding(
        get: { model.name },
        set: { model.name = $0 }
    ))
}

// ✅ Use @Bindable or computed binding
var body: some View {
    @Bindable var model = model
    TextField("Name", text: $model.name)
}

❌ アンチパターン 7:クロージャー内での循環状態

.sheet、.fullScreenCover、NavigationStack の遷移先、.popover などの @ViewBuilder クロージャーは、親の状態が変わるたびに再評価されます。子のコールバックが、子の初期化パラメーターとして渡された同じ親の @State を変更すると、子はライフサイクルの途中で変更後の値を使って再初期化されます。

// ❌ Callback mutates the same state passed as init param
.sheet(item: $sheetItem) { _ in
    ChildView(
        savedResponse: cachedResponse,      // ❌ Parent state as init param
        onSuccess: { cachedResponse = $0 }  // ❌ Mutates same state
    )
}

// ✅ Don't pass state that callbacks will mutate
.sheet(item: $sheetItem) { _ in
    ChildView(
        onSuccess: { cachedResponse = $0 }  // Update parent, but don't read it back
    )
}

問題点:

  • コールバックが、クロージャーが依存する親の状態を変更します
  • 親が再評価され、変更された値を使ってクロージャーも再評価されます
  • 子が読み込み状態やアニメーション状態を何も表示せずにスキップします。クラッシュはせず、動作が誤るだけです

修正方法:(1)変更される状態を初期化パラメーターとして渡し戻さないでください。(2)子の表示ロジック用に別の @State を使用します。(3)子自身がデータソースに問い合わせるようにします。診断ワークフローの詳細については、axiom-swiftui-debugging の根本原因 5 を参照してください。


コードレビューのチェックリスト

SwiftUI コードをマージする前に、次の点を確認してください。

ビュー
  • ビュー本体には UI コード(Text、Button、List など)だけが含まれている
  • ビュー本体でフォーマッターを作成していない
  • ビュー本体で計算や変換を行っていない
  • ビュー本体で API 呼び出しやデータベースクエリを実行していない
  • ビュー本体にビジネスルールがない
ロジックの分離
  • ビジネスロジックはモデルにある(SwiftUI なしでテストできる)
  • 表示ロジックは ViewModel または計算プロパティにある
  • 副作用はサービスまたはモデルのメソッドにある
  • 負荷の高い計算はキャッシュするか、一度だけ計算する
プロパティラッパー
  • ビューが所有するモデルには @State を使用する
  • アプリ全体のモデルには @Environment を使用する
  • バインディングが必要な場合は @Bindable を使用する
  • 読み取りのみの場合はラッパーを使用しない
アニメーションと非同期処理
  • アニメーション対象の状態変更は同期的に行う
  • 非同期処理との境界には State-as-Bridge パターンを使用する
  • withAnimation { } ブロックの間に await を置かない
テスト容易性
  • SwiftUI をインポートせずにビジネスロジックをテストできる
  • ビューをレンダリングせずに ViewModel をテストできる
  • (Coordinator を使用する場合)ナビゲーションロジックが分離されている

プレッシャーのかかる状況

シナリオ 1:「とりあえずビューに入れておいて」

プレッシャー

マネージャー:「金曜日までにこの機能が必要です。とりあえずロジックをビューに入れてください。あとでリファクタリングします。」

危険信号

次のような言葉を聞いたら注意してください。

  • ❌ 「あとでリファクタリングします」(返済されることのない技術的負債)
  • ❌ 「ビューは 1 つだけです」(ビューは増えていきます)
  • ❌ 「アーキテクチャに時間をかける余裕はありません」(あとでさらにコストがかかります)
所要時間の比較

選択肢 A:ロジックをビューに入れる

  • ビューに機能を実装:2 時間
  • テストできないと気づく:1 時間
  • それでもテストを試みる:2 時間
  • あきらめて手動テストだけでリリースする:0 時間
  • 合計:5 時間、テスト 0 件

選択肢 B:ロジックを適切に抽出する

  • モデル/ViewModel を作成:30 分
  • ロジックを分離して機能を実装:2 時間
  • テストを作成:1 時間
  • 合計:3.5 時間、テストカバレッジ 100%
プロフェッショナルな方法で反論する

Step 1: 期限を認識する

「金曜日が期限なのは理解しています。適切に分離したほうが、実際には速くなる理由をご説明します。」

Step 2: 所要時間を比較して示す

「ロジックをビューに入れると、テストなしで5時間かかります。適切に抽出すれば、テスト込みで3.5時間です。1.5時間節約できるうえに、テストも作成できます。」

Step 3: 妥協案を提示する

「本当に時間がないなら、今すぐ80%を抽出して、残りの20%はチケットを作成して技術的負債として記録できます。ただし、抽出を完全に省略するのはやめましょう。」

Step 4: 強行するよう求められた場合は記録する

// TODO: TECH DEBT - Extract business logic to ViewModel
// Ticket: PROJ-123
// Added: 2025-12-14
// Reason: Deadline pressure from manager
// Estimated refactor time: 2 hours
省略してもよい場合

抽出を省略するのは、次の場合に限ります。

  1. 使い捨てのプロトタイプである(来週には削除する)
  2. リファクタリングのための時間が明示的に確保されている(チケットが予定されている)
  3. ビューが20行を超えて増えることはない

シナリオ 2:「TCA は過剰です。素の SwiftUI を使いましょう」

プレッシャー

テックリード:「TCA はこのプロジェクトには複雑すぎます。@Observable を使った素の SwiftUI にしましょう。」

判断基準

次の質問を検討してください。

質問TCA素の SwiftUI
テスト可能性は重要ですか(医療、金融など)?✅❌
画面は5つ未満ですか?❌✅
チームは関数型プログラミングに精通していますか?✅❌
迅速なプロトタイピングが必要ですか?❌✅
大規模なチーム全体での一貫性は重要ですか?✅❌
複雑な副作用(ソケット、タイマーなど)がありますか?✅~

推奨マトリックス:

  • TCA のチェックが4つ以上 → TCA を使います
  • 素の SwiftUI のチェックが4つ以上 → 素の SwiftUI を使います
  • 同数 → まず素の SwiftUI から始め、必要になったら TCA に移行します
反論する方法

TCA に賛成する場合:

「TCA が重く感じられるのは理解しています。しかし、私たちは銀行アプリを開発しています。TestStore を使えば、リリース前にバグを見つけられる徹底的なテストができます。2年間の保守を考えれば、2週間の学習期間には価値があります。」

TCA に反対する場合:

「TCA が強力なのは認めますが、私たちは毎週機能のプロトタイプを作っています。定型コードが作業を遅らせます。まずは @Observable を使い、機能に開発する価値があるとわかったら TCA に移行しましょう。」

シナリオ 3:「リファクタリングには時間がかかりすぎます」

プレッシャー

PM:「今月は3つの機能をリリースする予定です。既存のビューのリファクタリングに2週間もかけられません。」

段階的な抽出戦略

すべてを一度にリファクタリングする必要はありません。

第1週:ビューを1つ抽出する

  • 最も問題の多いビューを選ぶ(ロジックが多いもの)
  • ViewModel に抽出する
  • テストを書く
  • 時間:4時間

第2週:ビューを2つ抽出する

  • これで参考にできるパターンができます
  • 第1週より速く進められます
  • 時間:6時間

第3週:新機能には適切なアーキテクチャを使う

  • まだ古いコードはリファクタリングしない
  • すべての新しいコードでこのパターンに従う
  • 時間:0時間(これまでと同じ)

第2か月:ファイルに手を入れる際に段階的にリファクタリングする

  • 古いビューのバグを修正するときにリファクタリングする
  • 古いビューに機能を追加するときにリファクタリングする
  • 時間:機能開発の作業に分散
反論する方法

「機能開発を2週間止める提案ではありません。次のように進める提案です。

  1. 第1週:最も問題の多いビュー(500行ある OrdersView)を抽出する
  2. 第2週:問題の多いビューをさらに2つ抽出する
  3. 以降、すべての新機能で適切なアーキテクチャを使う
  4. 古いビューは、いずれ手を入れるときにリファクタリングする

最初に10時間かかりますが、以降は機能ごとに2時間以上を節約できます。」


実際の効果

Before:ビュー内のロジック

// 😰 200 lines of pain
struct OrderListView: View {
    @State private var orders: [Order] = []
    @State private var searchText = ""
    @State private var selectedFilter: FilterType = .all

    var body: some View {
        // ❌ Formatters created every render
        let currencyFormatter = NumberFormatter()
        currencyFormatter.numberStyle = .currency

        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .medium

        // ❌ Business logic in view
        let filtered = orders.filter { order in
            if !searchText.isEmpty && !order.customerName.contains(searchText) {
                return false
            }

            switch selectedFilter {
            case .all: return true
            case .pending: return !order.isCompleted
            case .completed: return order.isCompleted
            case .highValue: return order.total > 1000
            }
        }

        // ❌ More business logic
        let sorted = filtered.sorted { lhs, rhs in
            if selectedFilter == .highValue {
                return lhs.total > rhs.total
            } else {
                return lhs.date > rhs.date
            }
        }

        return List(sorted) { order in
            VStack(alignment: .leading) {
                Text(order.customerName)
                Text(currencyFormatter.string(from: order.total as NSNumber)!)
                Text(dateFormatter.string(from: order.date))

                if order.isCompleted {
                    Image(systemName: "checkmark.circle.fill")
                } else {
                    Button("Complete") {
                        // ❌ Async logic in view
                        Task {
                            do {
                                try await completeOrder(order)
                                await loadOrders()
                            } catch {
                                print(error)  // ❌ No error handling
                            }
                        }
                    }
                }
            }
        }
        .searchable(text: $searchText)
        .task {
            await loadOrders()
        }
    }

    func loadOrders() async {
        // ❌ API call in view
        // ... 50 more lines
    }

    func completeOrder(_ order: Order) async throws {
        // ❌ API call in view
        // ... 30 more lines
    }
}

問題点:

  • 1つのファイルに200行以上ある
  • レンダリングのたびにフォーマッターが作成される(パフォーマンス)
  • ビジネスロジックをテストできない
  • エラー処理がない
  • 理解しにくい

After:適切なアーキテクチャ

// Model — 30 lines
struct Order {
    let id: UUID
    let customerName: String
    let total: Decimal
    let date: Date
    var isCompleted: Bool

    var isHighValue: Bool {
        total > 1000
    }
}

// ViewModel — 60 lines
@Observable
class OrderListViewModel {
    private let orderService: OrderService
    private let currencyFormatter = NumberFormatter()
    private let dateFormatter = DateFormatter()

    var orders: [Order] = []
    var searchText = ""
    var selectedFilter: FilterType = .all
    var error: Error?

    var filteredOrders: [Order] {
        orders
            .filter(matchesSearch)
            .filter(matchesFilter)
            .sorted(by: sortComparator)
    }

    init(orderService: OrderService) {
        self.orderService = orderService
        currencyFormatter.numberStyle = .currency
        dateFormatter.dateStyle = .medium
    }

    func loadOrders() async {
        do {
            orders = try await orderService.fetchOrders()
        } catch {
            self.error = error
        }
    }

    func completeOrder(_ order: Order) async {
        do {
            try await orderService.complete(order.id)
            await loadOrders()
        } catch {
            self.error = error
        }
    }

    func formattedTotal(_ order: Order) -> String {
        currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00"
    }

    func formattedDate(_ order: Order) -> String {
        dateFormatter.string(from: order.date)
    }

    private func matchesSearch(_ order: Order) -> Bool {
        searchText.isEmpty || order.customerName.contains(searchText)
    }

    private func matchesFilter(_ order: Order) -> Bool {
        switch selectedFilter {
        case .all: true
        case .pending: !order.isCompleted
        case .completed: order.isCompleted
        case .highValue: order.isHighValue
        }
    }

    private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool {
        selectedFilter == .highValue
            ? lhs.total > rhs.total
            : lhs.date > rhs.date
    }
}

// View — 40 lines
struct OrderListView: View {
    @Bindable var viewModel: OrderListViewModel

    var body: some View {
        List(viewModel.filteredOrders) { order in
            OrderRow(order: order, viewModel: viewModel)
        }
        .searchable(text: $viewModel.searchText)
        .task {
            await viewModel.loadOrders()
        }
        .alert("Error", error: $viewModel.error) { }
    }
}

struct OrderRow: View {
    let order: Order
    let viewModel: OrderListViewModel

    var body: some View {
        VStack(alignment: .leading) {
            Text(order.customerName)
            Text(viewModel.formattedTotal(order))
            Text(viewModel.formattedDate(order))

            if order.isCompleted {
                Image(systemName: "checkmark.circle.fill")
            } else {
                Button("Complete") {
                    Task {
                        await viewModel.completeOrder(order)
                    }
                }
            }
        }
    }
}

// Tests — 100 lines
final class OrderViewModelTests: XCTestCase {
    func testFilterBySearch() async {
        let viewModel = OrderListViewModel(orderService: MockOrderService())
        await viewModel.loadOrders()

        viewModel.searchText = "John"
        XCTAssertEqual(viewModel.filteredOrders.count, 1)
    }

    func testFilterByHighValue() async {
        let viewModel = OrderListViewModel(orderService: MockOrderService())
        await viewModel.loadOrders()

        viewModel.selectedFilter = .highValue
        XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue })
    }

    // ... 10 more tests
}

メリット:

  • ビュー:40行(以前は200行)
  • ViewModel:SwiftUI を使わずに完全にテスト可能
  • モデル:純粋なビジネスロジック
  • フォーマッター:レンダリングのたびではなく、一度だけ作成
  • エラー処理:アラートを使った適切な処理
  • テスト:すべてのロジックを網羅する10件以上のテスト

Resources

WWDC: 2025-266, 2024-10150, 2023-10149, 2023-10160

Docs: /swiftui/managing-model-data-in-your-app

External: github.com/pointfreeco/swift-composable-architecture


Platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ Xcode: 26+ Status: Production-ready (v1.0)

コメント