mcpField
M
mvvm-architecture

mvvm-architecture

github.com/kaakati開発1056 回インストール39 週間前に更新
リポジトリ

mvvm-architecture について

iOS と tvOS アプリで MVVM パターンを選ぶための判断ガイドです。状態やレイヤーの選択、依存性注入、アンチパターン、ViewModel のテスト方法を扱います。

主な機能

  1. 01ViewModel の状態パターン選択ツリー
  2. 02アプリ各レイヤーのロジック配置指針
  3. 03依存性注入パターン
  4. 04ViewModel とサービスのアンチパターン
  5. 05ViewModel テストチェックリスト

活用シーン

  1. 01画面の状態表現の選択
  2. 02API やドメインロジックの配置判断
  3. 03テスト可能な ViewModel 依存関係の設計

インストール

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

$npx skills add https://github.com/kaakati/rails-enterprise-dev --skill mvvm-architecture

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

同梱ファイルreferences/complete-viewmodel-patterns.md

詳細

MVVM アーキテクチャ — 専門的な判断

iOS/tvOS の MVVM に関する選択を支援する、専門的な判断フレームワークです。Claude は MVVM の基本を理解しています。このスキルでは、判断が難しい場面での選択を支援します。


意思決定ツリー

ViewModel パターンの選択
Does the screen have distinct, mutually exclusive states?
├─ YES (loading → loaded → error)
│  └─ State Enum Pattern
│     @Published var state: State = .idle
│     enum State { case idle, loading, loaded(Data), error(String) }
│
└─ NO (multiple independent properties)
   └─ Does the screen need form validation?
      ├─ YES → Combine Pattern (publishers for validation chains)
      └─ NO → Published Properties Pattern (simplest)

State Enum が適している場合:商品詳細(読み込み中 → 商品 → エラー)、認証フロー、ウィザードの各ステップです。すべてのケースを網羅的に処理する必要があります。

Published プロパティが適している場合:複数の独立したセクションがあり、それぞれ個別に読み込みや失敗が起きるダッシュボードです。組み合わせが 2^n 通りになると、State Enum は扱いにくくなります。

ロジックを配置する場所
Is it data transformation for display?
├─ YES → ViewModel (formatting, filtering visible data)
│
└─ NO → Is it reusable business logic?
   ├─ YES → Service Layer (API calls, validation rules, caching)
   │
   └─ NO → Is it pure domain logic?
      ├─ YES → Model (computed properties, domain rules)
      └─ NO → Reconsider if it's needed

落とし穴:API 呼び出しを ViewModel に直接記述することです。単純なサービスのモックではなく、ネットワークのモックが必要なテストになってしまいます。

@StateObject の注入
Does ViewModel need dependencies from parent?
├─ NO → Direct initialization
│  @StateObject private var viewModel = UserViewModel()
│
└─ YES → How many dependencies?
   ├─ 1-2 → Init parameter
   │  init(userId: String) {
   │      _viewModel = StateObject(wrappedValue: UserViewModel(userId: userId))
   │  }
   │
   └─ Many → Factory/Container
      @StateObject private var viewModel: UserViewModel
      init() {
          _viewModel = StateObject(wrappedValue: Container.shared.makeUserViewModel())
      }

絶対に避けること

ViewModel のアンチパターン

ViewModel の init でデータを読み込むのは絶対に避けてください:

// ❌ Starts loading before view appears, can't cancel, can't retry
class BadViewModel: ObservableObject {
    init() {
        Task { await loadData() }  // Fire-and-forget in init
    }
}

// ✅ Load via .task modifier — automatic cancellation on disappear
struct GoodView: View {
    @StateObject var viewModel = GoodViewModel()
    var body: some View {
        content.task { await viewModel.loadData() }
    }
}

変更可能な状態を直接公開するのは絶対に避けてください:

// ❌ Anyone can mutate — no control over state transitions
class BadViewModel: ObservableObject {
    @Published var users: [User] = []  // Public setter
}

// ✅ private(set) — only ViewModel controls mutations
class GoodViewModel: ObservableObject {
    @Published private(set) var users: [User] = []

    func addUser(_ user: User) {
        // Validation, analytics, etc.
        users.append(user)
    }
}

ViewModel に UI 固有のコードを記述するのは絶対に避けてください:

// ❌ ViewModel knows about colors, fonts, formatters
class BadViewModel: ObservableObject {
    @Published var priceColor: Color = .green
    @Published var formattedDate: String = ""  // Pre-formatted for display
}

// ✅ Return data, let View handle presentation
class GoodViewModel: ObservableObject {
    @Published private(set) var price: Decimal = 0
    @Published private(set) var date: Date = .now
}
// View: Text(viewModel.price, format: .currency(code: "USD"))

何でもこなす巨大な ViewModel を作るのは絶対に避けてください:

// ❌ One ViewModel for entire feature area
class UserViewModel: ObservableObject {
    // Profile, settings, posts, friends, notifications, activity...
    // 50+ @Published properties, 30+ methods
}

// ✅ One ViewModel per screen/concern
class UserProfileViewModel: ObservableObject { }
class UserSettingsViewModel: ObservableObject { }
class UserPostsViewModel: ObservableObject { }
サービスレイヤーのアンチパターン

具象的な依存関係を使用するのは絶対に避けてください:

// ❌ Hard to test — must mock URLSession
class BadViewModel: ObservableObject {
    func loadUsers() async {
        let url = URL(string: "https://api.example.com/users")!
        let (data, _) = try await URLSession.shared.data(from: url)  // Concrete
    }
}

// ✅ Protocol dependency — inject mock for testing
protocol UserServiceProtocol {
    func fetchUsers() async throws -> [User]
}

class GoodViewModel: ObservableObject {
    private let userService: UserServiceProtocol

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
    }
}

タスクのキャンセルを無視するのは絶対に避けてください:

// ❌ Shows error for cancelled task (user navigated away)
func loadData() async {
    do {
        users = try await service.fetchUsers()
    } catch {
        errorMessage = error.localizedDescription  // CancellationError shows error!
    }
}

// ✅ Handle cancellation separately
func loadData() async {
    do {
        users = try await service.fetchUsers()
    } catch is CancellationError {
        return  // User navigated away — don't show error
    } catch {
        errorMessage = error.localizedDescription
    }
}

コアパターン

ViewModel の最小テンプレート
@MainActor
final class FeatureViewModel: ObservableObject {
    // MARK: - State
    @Published private(set) var items: [Item] = []
    @Published private(set) var isLoading = false
    @Published var error: Error?

    // MARK: - Dependencies
    private let service: ServiceProtocol

    init(service: ServiceProtocol = Service()) {
        self.service = service
    }

    // MARK: - Actions
    func loadItems() async {
        isLoading = true
        defer { isLoading = false }

        do {
            items = try await service.fetchItems()
        } catch is CancellationError {
            return
        } catch {
            self.error = error
        }
    }
}
State Enum パターン
@MainActor
final class DetailViewModel: ObservableObject {
    enum State: Equatable {
        case idle
        case loading
        case loaded(Item)
        case error(String)

        var item: Item? {
            guard case .loaded(let item) = self else { return nil }
            return item
        }
    }

    @Published private(set) var state: State = .idle

    func load(id: String) async {
        state = .loading
        do {
            let item = try await service.fetch(id: id)
            state = .loaded(item)
        } catch {
            state = .error(error.localizedDescription)
        }
    }
}

// View exhaustive handling
switch viewModel.state {
case .idle, .loading: ProgressView()
case .loaded(let item): ItemView(item: item)
case .error(let message): ErrorView(message: message)
}
サービスプロトコルパターン
// Protocol — the contract
protocol UserServiceProtocol {
    func fetchUser(id: String) async throws -> User
    func updateUser(_ user: User) async throws -> User
}

// Real implementation
final class UserService: UserServiceProtocol {
    private let client: NetworkClient

    func fetchUser(id: String) async throws -> User {
        try await client.request(.user(id: id))
    }
}

// Mock for testing
final class MockUserService: UserServiceProtocol {
    var stubbedUser: User?
    var fetchError: Error?
    var fetchCallCount = 0

    func fetchUser(id: String) async throws -> User {
        fetchCallCount += 1
        if let error = fetchError { throw error }
        return stubbedUser ?? User.mock()
    }
}

テスト戦略

ViewModel のテスト構成
@MainActor
final class UserViewModelTests: XCTestCase {
    var sut: UserViewModel!
    var mockService: MockUserService!

    override func setUp() {
        mockService = MockUserService()
        sut = UserViewModel(service: mockService)
    }

    func test_loadUser_success_updatesState() async {
        // Given
        mockService.stubbedUser = User.mock(name: "John")

        // When
        await sut.loadUser(id: "123")

        // Then
        XCTAssertEqual(sut.user?.name, "John")
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.error)
    }

    func test_loadUser_failure_setsError() async {
        // Given
        mockService.fetchError = NetworkError.noConnection

        // When
        await sut.loadUser(id: "123")

        // Then
        XCTAssertNil(sut.user)
        XCTAssertNotNil(sut.error)
    }
}

重要な項目をテストします:

  1. 成功時/失敗時の状態変化
  2. サービスメソッドが正しいパラメーターで呼び出されること
  3. 読み込み状態が正しく遷移すること
  4. エラー処理でクラッシュしないこと

テストしない項目:

  • SwiftUI のバインディング(Apple の責任範囲です)
  • サービスの実装(別のテストファイルでテストします)

依存性注入

シンプルな方法:デフォルト引数
// Most apps need nothing more complex
class UserViewModel: ObservableObject {
    init(service: UserServiceProtocol = UserService()) {
        self.service = service
    }
}

// Test: UserViewModel(service: MockUserService())
// Production: UserViewModel() — uses default
複雑な方法:ファクトリーコンテナ
// Only when you have many cross-cutting dependencies
@MainActor
final class Container {
    static let shared = Container()

    lazy var networkClient = NetworkClient()
    lazy var authService = AuthService(client: networkClient)
    lazy var userService = UserService(client: networkClient, auth: authService)

    func makeUserViewModel() -> UserViewModel {
        UserViewModel(service: userService)
    }
}

クイックリファレンス

レイヤーの責務
レイヤー含まれるもの例
Modelドメインデータ+純粋なロジックユーザー、注文、検証ルール
ViewModel画面の状態+UI ロジック読み込み/エラー状態、リストのフィルタリング
Serviceビジネス処理API 呼び出し、キャッシュ、永続化
View表示レイアウト、スタイル、アニメーション
ViewModel チェックリスト
  • クラスに @MainActor を指定する
  • @Published プロパティに private(set) を指定する
  • デフォルト値のあるプロトコルベースの依存関係を使用する
  • CancellationError を個別に処理する
  • UI 型(Color、Font など)を使用しない
  • ネットワーク/データベースを直接呼び出さない
  • UI フレームワークなしでテストできる

コメント