vip-clean-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseVIP Clean Architecture
VIP 整洁架构
Overview
概述
VIP (View-Interactor-Presenter) is Uncle Bob's Clean Architecture applied to iOS with strict unidirectional data flow. Unlike MVVM (bidirectional) or VIPER (complex 5-component), VIP uses 3 core components with protocol-based boundaries.
Core Principle: Data flows in one direction: View → Interactor → Presenter → View. No component ever calls backward in the cycle.
VIP(View-Interactor-Presenter)是将Uncle Bob的整洁架构应用于iOS的实现,采用严格的单向数据流。与MVVM(双向绑定)或VIPER(5个复杂组件)不同,VIP使用3个核心组件,并基于协议定义边界。
核心原则: 数据仅沿一个方向流动:View → Interactor → Presenter → View。任何组件都不得反向调用。
When to Use VIP
何时使用VIP
dot
digraph vip_decision {
"Enterprise app?" [shape=diamond];
"Max testability?" [shape=diamond];
"Complex business logic?" [shape=diamond];
"Team > 3 devs?" [shape=diamond];
"Use VIP" [shape=box, style=filled, fillcolor=lightgreen];
"Use MVVM" [shape=box, style=filled, fillcolor=lightblue];
"Enterprise app?" -> "Max testability?" [label="yes"];
"Enterprise app?" -> "Use MVVM" [label="no"];
"Max testability?" -> "Use VIP" [label="yes"];
"Max testability?" -> "Complex business logic?" [label="no"];
"Complex business logic?" -> "Team > 3 devs?" [label="yes"];
"Complex business logic?" -> "Use MVVM" [label="no"];
"Team > 3 devs?" -> "Use VIP" [label="yes"];
"Team > 3 devs?" -> "Use MVVM" [label="no"];
}Use VIP for:
- Enterprise iOS apps with strict quality requirements
- Features requiring 80%+ test coverage
- Complex business logic with multiple edge cases
- Multi-team projects needing clear boundaries
- Apps where testability > development speed
Use MVVM instead for:
- Standard iOS apps (most apps)
- Rapid prototyping or MVPs
- Simple CRUD applications
- Small teams (1-2 developers)
- Projects prioritizing delivery speed
dot
digraph vip_decision {
"Enterprise app?" [shape=diamond];
"Max testability?" [shape=diamond];
"Complex business logic?" [shape=diamond];
"Team > 3 devs?" [shape=diamond];
"Use VIP" [shape=box, style=filled, fillcolor=lightgreen];
"Use MVVM" [shape=box, style=filled, fillcolor=lightblue];
"Enterprise app?" -> "Max testability?" [label="yes"];
"Enterprise app?" -> "Use MVVM" [label="no"];
"Max testability?" -> "Use VIP" [label="yes"];
"Max testability?" -> "Complex business logic?" [label="no"];
"Complex business logic?" -> "Team > 3 devs?" [label="yes"];
"Complex business logic?" -> "Use MVVM" [label="no"];
"Team > 3 devs?" -> "Use VIP" [label="yes"];
"Team > 3 devs?" -> "Use MVVM" [label="no"];
}适合使用VIP的场景:
- 有严格质量要求的企业级iOS应用
- 需要80%以上测试覆盖率的功能
- 包含多个边缘情况的复杂业务逻辑
- 需要清晰边界的多团队项目
- 优先考虑可测试性而非开发速度的应用
适合使用MVVM的场景:
- 标准iOS应用(大多数应用)
- 快速原型开发或MVP
- 简单CRUD应用
- 小型团队(1-2名开发者)
- 优先考虑交付速度的项目
VIP Components
VIP组件
| Component | Responsibility | Testability | What It Must NOT Do |
|---|---|---|---|
| View | Display, user input, lifecycle events | UI tests only | Business logic, formatting, navigation |
| Interactor | Business logic, use cases, orchestration | 100% unit testable | UI updates, data formatting, navigation |
| Presenter | Format data for display, prepare view models | 100% unit testable | Business logic, network calls, persistence |
| Worker | External services (network, DB, APIs) | Mock/stub in tests | Business logic, data formatting |
| Router | Navigation, screen transitions | Integration tests | Business logic, data management |
| 组件 | 职责 | 可测试性 | 禁止操作 |
|---|---|---|---|
| View | 展示UI、处理用户输入、生命周期事件 | 仅可进行UI测试 | 业务逻辑、数据格式化、页面导航 |
| Interactor | 业务逻辑、用例实现、流程编排 | 100%可单元测试 | UI更新、数据格式化、页面导航 |
| Presenter | 为展示格式化数据、准备视图模型 | 100%可单元测试 | 业务逻辑、网络请求、数据持久化 |
| Worker | 外部服务(网络、数据库、API) | 测试中使用Mock/Stub | 业务逻辑、数据格式化 |
| Router | 页面导航、屏幕转场 | 集成测试 | 业务逻辑、数据管理 |
VIP Data Flow (Critical)
VIP数据流(核心规则)
Rule: Data flows in ONE direction only. Never call backward in the cycle.
User Action → View → Interactor → Presenter → View
↑ ↓
└──────── Display ─────────────┘Detailed Flow:
- View captures user action → calls
Interactor.doSomething(request: Request) - Interactor executes business logic → calls
Presenter.presentSomething(response: Response) - Presenter formats data → calls
View.displaySomething(viewModel: ViewModel) - View updates UI with formatted data
Example: Login Flow
User taps "Login" button
→ View calls interactor.login(request: LoginRequest(email: "...", password: "..."))
→ Interactor validates input, calls Worker.authenticate(...)
→ Worker makes API call, returns Result<User, Error>
→ Interactor processes result, calls presenter.presentLoginResult(response: LoginResponse.success(user))
→ Presenter formats: "Welcome, John!" → calls view.displayWelcome(viewModel: LoginViewModel.success(message: "Welcome, John!"))
→ View updates UILabel.text = "Welcome, John!"规则: 数据仅沿一个方向流动。绝对不能反向调用。
用户操作 → View → Interactor → Presenter → View
↑ ↓
└────────── 展示UI ────────────┘详细流程:
- View捕获用户操作 → 调用
Interactor.doSomething(request: Request) - Interactor执行业务逻辑 → 调用
Presenter.presentSomething(response: Response) - Presenter格式化数据 → 调用
View.displaySomething(viewModel: ViewModel) - View使用格式化后的数据更新UI
示例:登录流程
用户点击“登录”按钮
→ View调用interactor.login(request: LoginRequest(email: "...", password: "..."))
→ Interactor验证输入,调用Worker.authenticate(...)
→ Worker发起API请求,返回Result<User, Error>
→ Interactor处理结果,调用presenter.presentLoginResult(response: LoginResponse.success(user))
→ Presenter格式化内容:"Welcome, John!" → 调用view.displayWelcome(viewModel: LoginViewModel.success(message: "Welcome, John!"))
→ View更新UILabel.text = "Welcome, John!"Protocol-Based Communication
基于协议的通信
Critical Rule: All components communicate via protocols, never concrete types.
swift
// MARK: - Protocols (VIP Cycle)
protocol LoginBusinessLogic {
func login(request: LoginRequest)
}
protocol LoginPresentationLogic {
func presentLoginResult(response: LoginResponse)
}
protocol LoginDisplayLogic: AnyObject {
func displayWelcome(viewModel: LoginViewModel)
func displayError(viewModel: LoginViewModel)
}
// MARK: - Data Models (Request → Response → ViewModel)
struct LoginRequest {
let email: String
let password: String
}
enum LoginResponse {
case success(user: User)
case failure(error: Error)
}
enum LoginViewModel {
case success(message: String)
case error(title: String, message: String)
}
// MARK: - View
final class LoginViewController: UIViewController {
var interactor: LoginBusinessLogic?
var router: LoginRoutingLogic?
@IBAction func loginButtonTapped() {
let request = LoginRequest(
email: emailTextField.text ?? "",
password: passwordTextField.text ?? ""
)
interactor?.login(request: request)
}
}
extension LoginViewController: LoginDisplayLogic {
func displayWelcome(viewModel: LoginViewModel) {
guard case .success(let message) = viewModel else { return }
welcomeLabel.text = message
router?.routeToHome()
}
func displayError(viewModel: LoginViewModel) {
guard case .error(let title, let message) = viewModel else { return }
showAlert(title: title, message: message)
}
}
// MARK: - Interactor
final class LoginInteractor: LoginBusinessLogic {
var presenter: LoginPresentationLogic?
var worker: LoginWorkerProtocol?
func login(request: LoginRequest) {
// Validation (business logic)
guard !request.email.isEmpty, !request.password.isEmpty else {
presenter?.presentLoginResult(response: .failure(error: ValidationError.emptyFields))
return
}
// Delegate to Worker for external service
worker?.authenticate(email: request.email, password: request.password) { [weak self] result in
switch result {
case .success(let user):
self?.presenter?.presentLoginResult(response: .success(user: user))
case .failure(let error):
self?.presenter?.presentLoginResult(response: .failure(error: error))
}
}
}
}
// MARK: - Presenter
final class LoginPresenter: LoginPresentationLogic {
weak var viewController: LoginDisplayLogic?
func presentLoginResult(response: LoginResponse) {
switch response {
case .success(let user):
let viewModel = LoginViewModel.success(message: "Welcome, \(user.name)!")
viewController?.displayWelcome(viewModel: viewModel)
case .failure(let error):
let viewModel = LoginViewModel.error(
title: "Login Failed",
message: error.localizedDescription
)
viewController?.displayError(viewModel: viewModel)
}
}
}
// MARK: - Worker
protocol LoginWorkerProtocol {
func authenticate(email: String, password: String, completion: @escaping (Result<User, Error>) -> Void)
}
final class LoginWorker: LoginWorkerProtocol {
func authenticate(email: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
// Network call, Core Data fetch, or external API
APIClient.shared.login(email: email, password: password, completion: completion)
}
}核心规则: 所有组件通过协议通信,绝不直接依赖具体类型。
swift
// MARK: - Protocols (VIP Cycle)
protocol LoginBusinessLogic {
func login(request: LoginRequest)
}
protocol LoginPresentationLogic {
func presentLoginResult(response: LoginResponse)
}
protocol LoginDisplayLogic: AnyObject {
func displayWelcome(viewModel: LoginViewModel)
func displayError(viewModel: LoginViewModel)
}
// MARK: - Data Models (Request → Response → ViewModel)
struct LoginRequest {
let email: String
let password: String
}
enum LoginResponse {
case success(user: User)
case failure(error: Error)
}
enum LoginViewModel {
case success(message: String)
case error(title: String, message: String)
}
// MARK: - View
final class LoginViewController: UIViewController {
var interactor: LoginBusinessLogic?
var router: LoginRoutingLogic?
@IBAction func loginButtonTapped() {
let request = LoginRequest(
email: emailTextField.text ?? "",
password: passwordTextField.text ?? ""
)
interactor?.login(request: request)
}
}
extension LoginViewController: LoginDisplayLogic {
func displayWelcome(viewModel: LoginViewModel) {
guard case .success(let message) = viewModel else { return }
welcomeLabel.text = message
router?.routeToHome()
}
func displayError(viewModel: LoginViewModel) {
guard case .error(let title, let message) = viewModel else { return }
showAlert(title: title, message: message)
}
}
// MARK: - Interactor
final class LoginInteractor: LoginBusinessLogic {
var presenter: LoginPresentationLogic?
var worker: LoginWorkerProtocol?
func login(request: LoginRequest) {
// 验证(业务逻辑)
guard !request.email.isEmpty, !request.password.isEmpty else {
presenter?.presentLoginResult(response: .failure(error: ValidationError.emptyFields))
return
}
// 委托Worker处理外部服务
worker?.authenticate(email: request.email, password: request.password) { [weak self] result in
switch result {
case .success(let user):
self?.presenter?.presentLoginResult(response: .success(user: user))
case .failure(let error):
self?.presenter?.presentLoginResult(response: .failure(error: error))
}
}
}
}
// MARK: - Presenter
final class LoginPresenter: LoginPresentationLogic {
weak var viewController: LoginDisplayLogic?
func presentLoginResult(response: LoginResponse) {
switch response {
case .success(let user):
let viewModel = LoginViewModel.success(message: "Welcome, \(user.name)!")
viewController?.displayWelcome(viewModel: viewModel)
case .failure(let error):
let viewModel = LoginViewModel.error(
title: "Login Failed",
message: error.localizedDescription
)
viewController?.displayError(viewModel: viewModel)
}
}
}
// MARK: - Worker
protocol LoginWorkerProtocol {
func authenticate(email: String, password: String, completion: @escaping (Result<User, Error>) -> Void)
}
final class LoginWorker: LoginWorkerProtocol {
func authenticate(email: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
// 网络请求、Core Data查询或外部API调用
APIClient.shared.login(email: email, password: password, completion: completion)
}
}VIP Testing Strategy (Spy Pattern)
VIP测试策略(Spy模式)
Critical Rule: Use Spy objects to verify protocol method calls, not XCTest assertions on properties.
核心规则: 使用Spy对象验证协议方法调用,而非对属性使用XCTest断言。
Why Spies over Mocks?
为什么选择Spy而非Mock?
- Spies record calls: Verify that correct methods were called with correct parameters
- Mocks return data: Provide predetermined responses for testing
- VIP needs Spies: We test protocol contracts, not implementation details
- Spy记录调用: 验证是否调用了正确的方法并传入了正确的参数
- Mock返回数据: 为测试提供预设响应
- VIP需要Spy: 我们测试的是协议契约,而非实现细节
Testing the Interactor
测试Interactor
swift
final class LoginInteractorTests: XCTestCase {
var sut: LoginInteractor!
var presenterSpy: LoginPresenterSpy!
var workerSpy: LoginWorkerSpy!
override func setUp() {
super.setUp()
sut = LoginInteractor()
presenterSpy = LoginPresenterSpy()
workerSpy = LoginWorkerSpy()
sut.presenter = presenterSpy
sut.worker = workerSpy
}
func testLoginWithValidCredentialsCallsWorker() {
// Given
let request = LoginRequest(email: "test@example.com", password: "password123")
// When
sut.login(request: request)
// Then
XCTAssertTrue(workerSpy.authenticateCalled)
XCTAssertEqual(workerSpy.authenticateEmail, "test@example.com")
XCTAssertEqual(workerSpy.authenticatePassword, "password123")
}
func testLoginWithEmptyEmailPresentsError() {
// Given
let request = LoginRequest(email: "", password: "password123")
// When
sut.login(request: request)
// Then
XCTAssertTrue(presenterSpy.presentLoginResultCalled)
if case .failure(let error) = presenterSpy.presentLoginResultResponse {
XCTAssertTrue(error is ValidationError)
} else {
XCTFail("Expected failure response")
}
}
}
// MARK: - Presenter Spy
final class LoginPresenterSpy: LoginPresentationLogic {
var presentLoginResultCalled = false
var presentLoginResultResponse: LoginResponse?
func presentLoginResult(response: LoginResponse) {
presentLoginResultCalled = true
presentLoginResultResponse = response
}
}
// MARK: - Worker Spy
final class LoginWorkerSpy: LoginWorkerProtocol {
var authenticateCalled = false
var authenticateEmail: String?
var authenticatePassword: String?
var authenticateResult: Result<User, Error> = .success(User(id: "1", name: "Test User"))
func authenticate(email: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
authenticateCalled = true
authenticateEmail = email
authenticatePassword = password
completion(authenticateResult)
}
}swift
final class LoginInteractorTests: XCTestCase {
var sut: LoginInteractor!
var presenterSpy: LoginPresenterSpy!
var workerSpy: LoginWorkerSpy!
override func setUp() {
super.setUp()
sut = LoginInteractor()
presenterSpy = LoginPresenterSpy()
workerSpy = LoginWorkerSpy()
sut.presenter = presenterSpy
sut.worker = workerSpy
}
func testLoginWithValidCredentialsCallsWorker() {
// 给定
let request = LoginRequest(email: "test@example.com", password: "password123")
// 执行
sut.login(request: request)
// 断言
XCTAssertTrue(workerSpy.authenticateCalled)
XCTAssertEqual(workerSpy.authenticateEmail, "test@example.com")
XCTAssertEqual(workerSpy.authenticatePassword, "password123")
}
func testLoginWithEmptyEmailPresentsError() {
// 给定
let request = LoginRequest(email: "", password: "password123")
// 执行
sut.login(request: request)
// 断言
XCTAssertTrue(presenterSpy.presentLoginResultCalled)
if case .failure(let error) = presenterSpy.presentLoginResultResponse {
XCTAssertTrue(error is ValidationError)
} else {
XCTFail("预期失败响应")
}
}
}
// MARK: - Presenter Spy
final class LoginPresenterSpy: LoginPresentationLogic {
var presentLoginResultCalled = false
var presentLoginResultResponse: LoginResponse?
func presentLoginResult(response: LoginResponse) {
presentLoginResultCalled = true
presentLoginResultResponse = response
}
}
// MARK: - Worker Spy
final class LoginWorkerSpy: LoginWorkerProtocol {
var authenticateCalled = false
var authenticateEmail: String?
var authenticatePassword: String?
var authenticateResult: Result<User, Error> = .success(User(id: "1", name: "Test User"))
func authenticate(email: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
authenticateCalled = true
authenticateEmail = email
authenticatePassword = password
completion(authenticateResult)
}
}Testing the Presenter
测试Presenter
swift
final class LoginPresenterTests: XCTestCase {
var sut: LoginPresenter!
var viewControllerSpy: LoginViewControllerSpy!
override func setUp() {
super.setUp()
sut = LoginPresenter()
viewControllerSpy = LoginViewControllerSpy()
sut.viewController = viewControllerSpy
}
func testPresentLoginSuccessFormatsWelcomeMessage() {
// Given
let user = User(id: "1", name: "John Doe")
let response = LoginResponse.success(user: user)
// When
sut.presentLoginResult(response: response)
// Then
XCTAssertTrue(viewControllerSpy.displayWelcomeCalled)
if case .success(let message) = viewControllerSpy.displayWelcomeViewModel {
XCTAssertEqual(message, "Welcome, John Doe!")
} else {
XCTFail("Expected success viewModel")
}
}
func testPresentLoginFailureFormatsErrorMessage() {
// Given
let error = NSError(domain: "Test", code: 401, userInfo: [NSLocalizedDescriptionKey: "Invalid credentials"])
let response = LoginResponse.failure(error: error)
// When
sut.presentLoginResult(response: response)
// Then
XCTAssertTrue(viewControllerSpy.displayErrorCalled)
if case .error(let title, let message) = viewControllerSpy.displayErrorViewModel {
XCTAssertEqual(title, "Login Failed")
XCTAssertEqual(message, "Invalid credentials")
} else {
XCTFail("Expected error viewModel")
}
}
}
// MARK: - View Spy
final class LoginViewControllerSpy: LoginDisplayLogic {
var displayWelcomeCalled = false
var displayWelcomeViewModel: LoginViewModel?
var displayErrorCalled = false
var displayErrorViewModel: LoginViewModel?
func displayWelcome(viewModel: LoginViewModel) {
displayWelcomeCalled = true
displayWelcomeViewModel = viewModel
}
func displayError(viewModel: LoginViewModel) {
displayErrorCalled = true
displayErrorViewModel = viewModel
}
}swift
final class LoginPresenterTests: XCTestCase {
var sut: LoginPresenter!
var viewControllerSpy: LoginViewControllerSpy!
override func setUp() {
super.setUp()
sut = LoginPresenter()
viewControllerSpy = LoginViewControllerSpy()
sut.viewController = viewControllerSpy
}
func testPresentLoginSuccessFormatsWelcomeMessage() {
// 给定
let user = User(id: "1", name: "John Doe")
let response = LoginResponse.success(user: user)
// 执行
sut.presentLoginResult(response: response)
// 断言
XCTAssertTrue(viewControllerSpy.displayWelcomeCalled)
if case .success(let message) = viewControllerSpy.displayWelcomeViewModel {
XCTAssertEqual(message, "Welcome, John Doe!")
} else {
XCTFail("预期成功视图模型")
}
}
func testPresentLoginFailureFormatsErrorMessage() {
// 给定
let error = NSError(domain: "Test", code: 401, userInfo: [NSLocalizedDescriptionKey: "Invalid credentials"])
let response = LoginResponse.failure(error: error)
// 执行
sut.presentLoginResult(response: response)
// 断言
XCTAssertTrue(viewControllerSpy.displayErrorCalled)
if case .error(let title, let message) = viewControllerSpy.displayErrorViewModel {
XCTAssertEqual(title, "Login Failed")
XCTAssertEqual(message, "Invalid credentials")
} else {
XCTFail("预期错误视图模型")
}
}
}
// MARK: - View Spy
final class LoginViewControllerSpy: LoginDisplayLogic {
var displayWelcomeCalled = false
var displayWelcomeViewModel: LoginViewModel?
var displayErrorCalled = false
var displayErrorViewModel: LoginViewModel?
func displayWelcome(viewModel: LoginViewModel) {
displayWelcomeCalled = true
displayWelcomeViewModel = viewModel
}
func displayError(viewModel: LoginViewModel) {
displayErrorCalled = true
displayErrorViewModel = viewModel
}
}Critical Rules
核心规则
✅ DO
✅ 必须遵守
- Protocol everything: All component communication via protocols
- Unidirectional flow: View → Interactor → Presenter → View (never backward)
- Three data models: Request (View→Interactor), Response (Interactor→Presenter), ViewModel (Presenter→View)
- Spy-based tests: Verify protocol method calls, not property assertions
- Worker isolation: All external services (network, DB, location) in Workers
- Presenter formats only: Strings, dates, colors, numbers prepared for display
- 所有组件基于协议: 所有组件通信都通过协议
- 单向数据流: View → Interactor → Presenter → View(绝不反向)
- 三种数据模型: Request(View→Interactor)、Response(Interactor→Presenter)、ViewModel(Presenter→View)
- 基于Spy的测试: 验证协议方法调用,而非属性断言
- Worker隔离外部服务: 所有外部服务(网络、数据库、定位)都放在Worker中
- 仅Presenter负责格式化: 字符串、日期、颜色、数字等都由Presenter准备用于展示
❌ DON'T
❌ 禁止操作
- Never skip the cycle: View must NOT call Presenter directly
- Never call backward: Presenter must NOT call Interactor
- Never mix ViewModels: VIP uses Presenter, not ViewModel classes
- Never use concrete types: Always depend on protocols
- Never put business logic in Presenter: Business logic belongs in Interactor
- Never put formatting in Interactor: Formatting belongs in Presenter
- 绝不跳过流程: View不能直接调用Presenter
- 绝不反向调用: Presenter不能调用Interactor
- 绝不混用ViewModel: VIP使用Presenter,而非ViewModel类
- 绝不依赖具体类型: 始终依赖协议
- 绝不将业务逻辑放在Presenter: 业务逻辑属于Interactor
- 绝不将格式化逻辑放在Interactor: 格式化属于Presenter
Anti-Patterns to Reject
需要避免的反模式
| Anti-Pattern | Why It's Wrong | Correct Approach |
|---|---|---|
| View calls Presenter directly | Breaks unidirectional flow, bypasses business logic | View always calls Interactor first |
| Presenter calls Interactor | Creates circular dependency, breaks cycle | Interactor calls Presenter, never reverse |
| Mixing ViewModel with VIP | ViewModel is MVVM concept, VIP uses Presenter | Remove ViewModel, use Presenter for formatting |
| Business logic in Presenter | Presenter should only format, not decide | Move validation/logic to Interactor |
| Interactor updates View | Violates separation, untestable | Interactor → Presenter → View path |
| Using concrete types | Hard to test, tight coupling | All components depend on protocols |
| 反模式 | 问题所在 | 正确做法 |
|---|---|---|
| View直接调用Presenter | 破坏单向数据流,绕过业务逻辑 | View必须先调用Interactor |
| Presenter调用Interactor | 产生循环依赖,破坏流程 | 只能由Interactor调用Presenter,反之不行 |
| VIP中混用ViewModel | ViewModel是MVVM的概念,VIP使用Presenter | 移除ViewModel,使用Presenter进行格式化 |
| Presenter中包含业务逻辑 | Presenter应仅负责格式化,而非决策 | 将验证/逻辑移至Interactor |
| Interactor直接更新View | 违反关注点分离,无法测试 | 必须通过Interactor → Presenter → View的路径 |
| 依赖具体类型 | 难以测试,耦合度高 | 所有组件都依赖协议 |
VIP vs MVVM vs VIPER
VIP vs MVVM vs VIPER
| Aspect | VIP | MVVM | VIPER |
|---|---|---|---|
| Components | 3 core (V-I-P) + Worker + Router | 2 (View + ViewModel) | 5 (V-I-P-E-R) |
| Data Flow | Unidirectional cycle | Bidirectional binding | Multi-directional |
| Testability | 100% (protocol-based Spies) | High (mock services) | 100% (protocol-based) |
| Complexity | Medium | Low | High |
| Best For | Enterprise apps, max testability | Most iOS apps | Complex multi-module apps |
| 维度 | VIP | MVVM | VIPER |
|---|---|---|---|
| 组件数量 | 3个核心(V-I-P)+ Worker + Router | 2个(View + ViewModel) | 5个(V-I-P-E-R) |
| 数据流 | 单向循环 | 双向绑定 | 多向流动 |
| 可测试性 | 100%(基于协议) | 高(Mock服务) | 100%(基于协议) |
| 复杂度 | 中等 | 低 | 高 |
| 最佳场景 | 企业级应用、最高可测试性 | 大多数iOS应用 | 复杂多模块应用 |
Scene Assembly (Dependency Injection)
场景组装(依赖注入)
Configurator Pattern:
swift
final class LoginConfigurator {
static func configure(_ viewController: LoginViewController) {
let interactor = LoginInteractor()
let presenter = LoginPresenter()
let router = LoginRouter()
let worker = LoginWorker()
viewController.interactor = interactor
viewController.router = router
interactor.presenter = presenter
interactor.worker = worker
presenter.viewController = viewController
router.viewController = viewController
}
}
// Usage in AppDelegate or SceneDelegate
let loginVC = LoginViewController()
LoginConfigurator.configure(loginVC)
present(loginVC, animated: true)配置器模式:
swift
final class LoginConfigurator {
static func configure(_ viewController: LoginViewController) {
let interactor = LoginInteractor()
let presenter = LoginPresenter()
let router = LoginRouter()
let worker = LoginWorker()
viewController.interactor = interactor
viewController.router = router
interactor.presenter = presenter
interactor.worker = worker
presenter.viewController = viewController
router.viewController = viewController
}
}
// 在AppDelegate或SceneDelegate中使用
let loginVC = LoginViewController()
LoginConfigurator.configure(loginVC)
present(loginVC, animated: true)Migration from MVVM to VIP
从MVVM迁移到VIP
Step 1: Identify the ViewModel
swift
// Before (MVVM)
class LoginViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
func login() async {
// Business logic + formatting mixed
}
}Step 2: Split into Interactor (business logic) + Presenter (formatting)
swift
// After (VIP)
// Interactor: Business logic only
class LoginInteractor: LoginBusinessLogic {
func login(request: LoginRequest) {
// Validation logic
// Call Worker
// Pass raw response to Presenter
}
}
// Presenter: Formatting only
class LoginPresenter: LoginPresentationLogic {
func presentLoginResult(response: LoginResponse) {
// Format user.name into "Welcome, John!"
// Create ViewModel with formatted strings
}
}Step 3: Add protocol boundaries
swift
protocol LoginBusinessLogic { ... }
protocol LoginPresentationLogic { ... }
protocol LoginDisplayLogic: AnyObject { ... }Step 4: Implement Spy-based tests
swift
class LoginPresenterSpy: LoginPresentationLogic { ... }
class LoginWorkerSpy: LoginWorkerProtocol { ... }步骤1:识别ViewModel
swift
// 迁移前(MVVM)
class LoginViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
func login() async {
// 业务逻辑与格式化混合
}
}步骤2:拆分为Interactor(业务逻辑)+ Presenter(格式化)
swift
// 迁移后(VIP)
// Interactor:仅负责业务逻辑
class LoginInteractor: LoginBusinessLogic {
func login(request: LoginRequest) {
// 验证逻辑
// 调用Worker
// 将原始响应传递给Presenter
}
}
// Presenter:仅负责格式化
class LoginPresenter: LoginPresentationLogic {
func presentLoginResult(response: LoginResponse) {
// 将user.name格式化为"Welcome, John!"
// 创建包含格式化字符串的ViewModel
}
}步骤3:添加协议边界
swift
protocol LoginBusinessLogic { ... }
protocol LoginPresentationLogic { ... }
protocol LoginDisplayLogic: AnyObject { ... }步骤4:实现基于Spy的测试
swift
class LoginPresenterSpy: LoginPresentationLogic { ... }
class LoginWorkerSpy: LoginWorkerProtocol { ... }References
参考资料
- Clean Architecture (Uncle Bob): https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- Clean Swift (VIP Creator): https://clean-swift.com/clean-swift-ios-architecture/
- VIP vs VIPER: https://clean-swift.com/viper-vs-vip/
Word count: ~2,100
For: Senior iOS engineers building enterprise apps
Focus: Unidirectional flow, protocol-based testability, Spy pattern
- Clean Architecture (Uncle Bob): https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- Clean Swift (VIP Creator): https://clean-swift.com/clean-swift-ios-architecture/
- VIP vs VIPER: https://clean-swift.com/viper-vs-vip/
字数统计: ~2,100
面向人群: 构建企业级应用的资深iOS工程师
核心重点: 单向数据流、基于协议的可测试性、Spy模式