mvvm-architecture について
iOS と tvOS アプリで MVVM パターンを選ぶための判断ガイドです。状態やレイヤーの選択、依存性注入、アンチパターン、ViewModel のテスト方法を扱います。
主な機能
- 01ViewModel の状態パターン選択ツリー
- 02アプリ各レイヤーのロジック配置指針
- 03依存性注入パターン
- 04ViewModel とサービスのアンチパターン
- 05ViewModel テストチェックリスト
活用シーン
- 01画面の状態表現の選択
- 02API やドメインロジックの配置判断
- 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)
}
}
重要な項目をテストします:
- 成功時/失敗時の状態変化
- サービスメソッドが正しいパラメーターで呼び出されること
- 読み込み状態が正しく遷移すること
- エラー処理でクラッシュしないこと
テストしない項目:
- 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 フレームワークなしでテストできる