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. 02判断 API 或领域逻辑的放置层
  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)

适合使用状态枚举的场景:产品详情(加载中 → 产品 → 错误)、身份验证流程、向导步骤。可强制处理所有情况。

适合使用已发布属性的场景:包含多个独立版块的仪表盘,各版块可独立加载或失败。当有 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)
    }
}

测试重要内容:

  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领域数据 + 纯逻辑User、Order、验证规则
ViewModel屏幕状态 + UI 逻辑加载/错误状态、列表筛选
Service业务操作API 调用、缓存、持久化
View展示布局、样式、动画
ViewModel 检查清单
  • 在类上使用 @MainActor
  • 在 @Published 属性上使用 private(set)
  • 使用基于协议的依赖并提供默认值
  • 单独处理 CancellationError
  • 不包含 UI 类型(Color、Font 等)
  • 不直接调用网络/数据库
  • 无需 UI 框架即可测试

评论

支持 Markdown