mvvm-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMVVM Architecture — Expert Decisions
MVVM架构——专家决策指南
Expert decision frameworks for MVVM choices in iOS/tvOS. Claude knows MVVM basics — this skill provides judgment calls for non-obvious decisions.
针对iOS/tvOS平台MVVM架构选择的专家决策框架。Claude已掌握MVVM基础内容——本技能针对非显而易见的决策场景提供判断依据。
Decision Trees
决策树
ViewModel Pattern Selection
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)When State Enum wins: Product detail (loading → product → error), authentication flows, wizard steps. Forces exhaustive handling.
When Published Properties win: Dashboard with multiple independent sections that load/fail independently. State enum becomes unwieldy with 2^n combinations.
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)状态枚举模式适用场景:商品详情页(加载中→加载完成→错误)、认证流程、向导步骤。该模式强制对所有状态进行全面处理。
发布属性模式适用场景:包含多个独立加载/失败模块的仪表盘。此时使用状态枚举会因2^n种组合变得难以维护。
Where Does Logic Belong?
业务逻辑归属判定
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 neededThe trap: Putting API calls directly in ViewModel. Makes testing require network mocking instead of simple service mocking.
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常见误区:直接在ViewModel中调用API。这会导致测试时需要模拟整个网络请求,而非仅模拟服务层。
@StateObject Injection
@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())
}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())
}NEVER Do
绝对禁止的操作
ViewModel Anti-Patterns
ViewModel反模式
NEVER load data in ViewModel :
initswift
// ❌ 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() }
}
}NEVER expose mutable state directly:
swift
// ❌ 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)
}
}NEVER put UI-specific code in ViewModel:
swift
// ❌ 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"))NEVER create god ViewModels:
swift
// ❌ 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 { }绝对不要在ViewModel的方法中加载数据:
initswift
// ❌ 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() }
}
}绝对不要直接暴露可变状态:
swift
// ❌ 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相关代码:
swift
// ❌ 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:
swift
// ❌ 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 { }Service Layer Anti-Patterns
服务层反模式
NEVER use concrete dependencies:
swift
// ❌ 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
}
}NEVER ignore task cancellation:
swift
// ❌ 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
}
}绝对不要使用具体依赖:
swift
// ❌ 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
}
}绝对不要忽略任务取消:
swift
// ❌ 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
}
}Core Patterns
核心模式
Minimal ViewModel Template
最简ViewModel模板
swift
@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
}
}
}swift
@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 Pattern
状态枚举模式
swift
@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)
}swift
@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)
}Service Protocol Pattern
服务层协议模式
swift
// 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()
}
}swift
// 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()
}
}Testing Strategy
测试策略
ViewModel Test Structure
ViewModel测试结构
swift
@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)
}
}Test what matters:
- State changes on success/failure
- Service method called with correct parameters
- Loading states transition correctly
- Error handling doesn't crash
Don't test:
- SwiftUI bindings (Apple's responsibility)
- Service implementation (separate test file)
swift
@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负责维护)
- 服务层实现(单独编写测试文件)
Dependency Injection
依赖注入
Simple: Default Parameters
简单方案:默认参数
swift
// Most apps need nothing more complex
class UserViewModel: ObservableObject {
init(service: UserServiceProtocol = UserService()) {
self.service = service
}
}
// Test: UserViewModel(service: MockUserService())
// Production: UserViewModel() — uses defaultswift
// Most apps need nothing more complex
class UserViewModel: ObservableObject {
init(service: UserServiceProtocol = UserService()) {
self.service = service
}
}
// Test: UserViewModel(service: MockUserService())
// Production: UserViewModel() — uses defaultComplex: Factory Container
复杂方案:工厂容器
swift
// 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)
}
}swift
// 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)
}
}Quick Reference
快速参考
Layer Responsibilities
各层职责
| Layer | Contains | Examples |
|---|---|---|
| Model | Domain data + pure logic | User, Order, validation rules |
| ViewModel | Screen state + UI logic | Loading/error states, list filtering |
| Service | Business operations | API calls, caching, persistence |
| View | Presentation | Layout, styling, animations |
| 层级 | 包含内容 | 示例 |
|---|---|---|
| Model | 领域数据+纯逻辑 | User、Order、验证规则 |
| ViewModel | 页面状态+UI逻辑 | 加载/错误状态、列表过滤 |
| Service | 业务操作 | API调用、缓存、持久化 |
| View | 视图展示 | 布局、样式、动画 |
ViewModel Checklist
ViewModel检查清单
- on class
@MainActor - on @Published properties
private(set) - Protocol-based dependencies with defaults
- CancellationError handled separately
- No UI types (Color, Font, etc.)
- No direct network/database calls
- Testable without UI framework
- 类标记
@MainActor - @Published属性使用
private(set) - 基于协议的依赖并提供默认实现
- 单独处理CancellationError
- 不包含UI类型(Color、Font等)
- 不直接调用网络/数据库
- 无需UI框架即可测试