关于 mvvm-architecture
面向 iOS 和 tvOS 开发者的 MVVM 决策指南,帮助选择合适的架构模式。内容涵盖状态与分层、依赖注入、常见反模式及 ViewModel 测试方法。
主要功能
- 01ViewModel 状态模式决策树
- 02应用各层逻辑职责建议
- 03依赖注入模式
- 04ViewModel 与服务层反模式
- 05ViewModel 测试清单
适用场景
- 01为页面选择状态表示方式
- 02判断 API 或领域逻辑的放置层
- 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)
适合使用状态枚举的场景:产品详情(加载中 → 产品 → 错误)、身份验证流程、向导步骤。可强制处理所有情况。
适合使用已发布属性的场景:包含多个独立版块的仪表盘,各版块可独立加载或失败。当有 2^n 种组合时,状态枚举会变得难以管理。
逻辑应该放在哪里?
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 注入
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
}
}
}
状态枚举模式
@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 | 领域数据 + 纯逻辑 | User、Order、验证规则 |
| ViewModel | 屏幕状态 + UI 逻辑 | 加载/错误状态、列表筛选 |
| Service | 业务操作 | API 调用、缓存、持久化 |
| View | 展示 | 布局、样式、动画 |
ViewModel 检查清单
- 在类上使用
@MainActor - 在 @Published 属性上使用
private(set) - 使用基于协议的依赖并提供默认值
- 单独处理 CancellationError
- 不包含 UI 类型(Color、Font 等)
- 不直接调用网络/数据库
- 无需 UI 框架即可测试
评论
支持 Markdown