从 MVC 到 MVP 再到 MVVM

35 阅读7分钟

在 iOS 开发的浪潮中,应用架构模式经历了从经典的 MVC(Model-View-Controller)到 MVP(Model-View-Presenter),再到如今备受推崇的 MVVM(Model-View-ViewModel)的演进。这一演进过程并非简单的技术更迭,其背后蕴含着对软件工程中“关注点分离”(Separation of Concerns)原则的不断深化理解,以及对提升代码可测试性、可维护性和可扩展性的不懈追求。

核心演进动力:解决“胖控制器”与提升代码质量

MVC(模型-视图-控制器)  是苹果官方推荐的经典架构模式。在这种模式下:

  • Model(模型) :负责管理应用程序的数据和业务逻辑。
  • View(视图) :负责展示数据,并接收用户的交互。
  • Controller(控制器) :作为模型和视图之间的协调者,处理用户输入,更新模型,并将模型的变化反映到视图上。

然而,在实践中,尤其是在复杂的 iOS 应用中,MVC 模式常常导致一个被称为“胖控制器”(Massive View Controller)的问题。大量的业务逻辑、网络请求、数据处理和视图更新代码都集中在 UIViewController 中,使其变得异常臃肿和难以维护。此外,由于视图和控制器紧密耦合,单元测试也变得异常困难。

MVP(模型-视图-提供者)  模式的出现,正是为了解决 MVC 中控制器过于臃肿以及视图与模型直接耦合的问题。在 MVP 模式下:

  • Model(模型) :职责与 MVC 中类似。
  • View(视图) :变得更加“被动”,只负责 UI 的展示和将用户事件传递给 Presenter。在 iOS 中,UIViewController 和 UIView 通常被视为视图层的一部分。
  • Presenter(提供者) :取代了 Controller 的大部分职责,包含了所有的业务逻辑和表示逻辑。它从模型获取数据,处理后更新视图。视图和模型之间不再有直接的联系,它们通过 Presenter 进行通信。

MVP 通过引入 Presenter,成功地为 UIViewController “瘦身”,并将业务逻辑从视图层中抽离出来,从而提高了代码的可测试性。

MVVM(模型-视图-视图模型)  模式则是在 MVP 的基础上,通过引入数据绑定(Data Binding)机制,进一步优化了视图和业务逻辑之间的通信方式。

  • Model(模型) :职责保持不变。
  • View(视图) :在 MVVM 中,视图层(UIViewController 和 UIView)的角色更加纯粹,主要负责界面的展示。
  • ViewModel(视图模型) :它暴露视图所需的数据和命令。ViewModel 从 Model 获取数据,并将其转换为视图可以直接绑定的属性。当 ViewModel 的数据变化时,通过数据绑定机制,视图会自动更新。

MVVM 的核心优势在于其强大的数据绑定机制(在 Swift 中可以通过 KVO、闭包、或者结合像 RxSwift/Combine 这样的响应式编程框架来实现),这使得视图和 ViewModel 之间的同步逻辑被大大简化,进一步降低了耦合度,并极大地提升了代码的可测试性和可维护性。[1][2][3]

登录逻辑代码对比:直观感受架构差异

下面我们通过一个完整的登录逻辑,来直观地对比这三种架构模式在代码实现上的差异。


场景设定

用户在登录界面输入用户名和密码,点击登录按钮。应用将验证输入,模拟一个网络请求,并根据请求结果更新界面,显示成功或失败的信息。

Model (通用)

对于三种模式,我们使用相同的 User 模型和 AuthService 服务类。

codeSwift

// Model/User.swift
struct User {
    let username: String
}

// Service/AuthService.swift
class AuthService {
    func login(withUsername username: String, password Bpassword: String, completion: @escaping (Result<User, Error>) -> Void) {
        // 模拟网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            if username == "test" && password == "password" {
                completion(.success(User(username: "test")))
            } else {
                completion(.failure(NSError(domain: "AuthError", code: 401, userInfo: [NSLocalizedDescriptionKey: "无效的用户名或密码"])))
            }
        }
    }
}

MVC 实现

在 MVC 中,LoginViewController 承担了所有的协调工作。

codeSwift

// Controllers/LoginViewController.swift
import UIKit

class LoginViewController: UIViewController {

    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var statusLabel: UILabel!
    @IBOutlet weak var loginButton: UIButton!

    private let authService = AuthService()

    override func viewDidLoad() {
        super.viewDidLoad()
        statusLabel.text = ""
    }

    @IBAction func loginButtonTapped(_ sender: UIButton) {
        guard let username = usernameTextField.text, !username.isEmpty,
              let password = passwordTextField.text, !password.isEmpty else {
            updateStatus(with: "请输入用户名和密码")
            return
        }

        setLoading(true)
        authService.login(withUsername: username, password: password) { [weak self] result in
            guard let self = self else { return }
            self.setLoading(false)

            switch result {
            case .success(let user):
                self.updateStatus(with: "欢迎, (user.username)!")
                // 导航到主界面等操作...
            case .failure(let error):
                self.updateStatus(with: error.localizedDescription)
            }
        }
    }

    private func updateStatus(with message: String) {
        statusLabel.text = message
    }

    private func setLoading(_ loading: Bool) {
        loginButton.isEnabled = !loading
        if loading {
            loginButton.setTitle("登录中...", for: .disabled)
        } else {
            loginButton.setTitle("登录", for: .normal)
        }
    }
}

分析:可以看到,LoginViewController 直接与 AuthService 交互,处理登录逻辑,并负责更新 UI。所有的职责都集中在一个类中,当业务逻辑变得复杂时,这个类会迅速膨胀。


MVP 实现

在 MVP 中,我们引入 LoginPresenter 来处理业务逻辑。

首先,定义 LoginView 协议,由 LoginViewController 实现。

codeSwift

// Protocols/LoginView.swift
protocol LoginView: AnyObject {
    func displayLoginSuccess(viewModel: LoginViewModel)
    func displayLoginFailure(message: String)
    func setLoading(loading: Bool)
}

LoginViewModel 用于传递格式化好的数据给视图。

codeSwift

// ViewModels/LoginViewModel.swift
struct LoginViewModel {
    let successMessage: String
}

然后是 LoginPresenter。

codeSwift

// Presenters/LoginPresenter.swift
class LoginPresenter {
    private weak var view: LoginView?
    private let authService: AuthService

    init(view: LoginView, authService: AuthService = AuthService()) {
        self.view = view
        self.authService = authService
    }

    func login(username: String?, password: String?) {
        guard let username = username, !username.isEmpty,
              let password = password, !password.isEmpty else {
            view?.displayLoginFailure(message: "请输入用户名和密码")
            return
        }

        view?.setLoading(true)
        authService.login(withUsername: username, password: password) { [weak self] result in
            guard let self = self else { return }
            self.view?.setLoading(loading: false)

            switch result {
            case .success(let user):
                let viewModel = LoginViewModel(successMessage: "欢迎, (user.username)!")
                self.view?.displayLoginSuccess(viewModel: viewModel)
            case .failure(let error):
                self.view?.displayLoginFailure(message: error.localizedDescription)
            }
        }
    }
}

最后是瘦身后的 LoginViewController。

codeSwift

// Views/LoginViewController.swift
import UIKit

class LoginViewController: UIViewController, LoginView {

    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var statusLabel: UILabel!
    @IBOutlet weak var loginButton: UIButton!

    private var presenter: LoginPresenter!

    override func viewDidLoad() {
        super.viewDidLoad()
        presenter = LoginPresenter(view: self)
        statusLabel.text = ""
    }

    @IBAction func loginButtonTapped(_ sender: UIButton) {
        presenter.login(username: usernameTextField.text, password: passwordTextField.text)
    }

    func displayLoginSuccess(viewModel: LoginViewModel) {
        statusLabel.text = viewModel.successMessage
        // 导航到主界面等操作...
    }

    func displayLoginFailure(message: String) {
        statusLabel.text = message
    }

    func setLoading(loading: Bool) {
        loginButton.isEnabled = !loading
        if loading {
            loginButton.setTitle("登录中...", for: .disabled)
        } else {
            loginButton.setTitle("登录", for: .normal)
        }
    }
}

分析:LoginViewController 的职责变得非常简单,只负责 UI 的更新和用户事件的传递。所有的业务逻辑都转移到了 LoginPresenter 中。LoginPresenter 通过协议与 LoginViewController 通信,实现了松耦合,使得 Presenter 变得易于测试。


MVVM 实现

在 MVVM 中,我们创建 LoginViewModel 来暴露视图所需的状态。

codeSwift

// ViewModels/LoginViewModel.swift
import Foundation

class LoginViewModel {

    // MARK: - Input
    let username = Observable<String?>(nil)
    let password = Observable<String?>(nil)

    // MARK: - Output
    let isLoading = Observable<Bool>(false)
    let statusMessage = Observable<String?>(nil)
    let loginSuccess = Observable<User?>(nil)

    private let authService: AuthService

    init(authService: AuthService = AuthService()) {
        self.authService = authService
    }

    func login() {
        guard let username = username.value, !username.isEmpty,
              let password = password.value, !password.isEmpty else {
            statusMessage.value = "请输入用户名和密码"
            return
        }

        isLoading.value = true
        authService.login(withUsername: username, password: password) { [weak self] result in
            guard let self = self else { return }
            self.isLoading.value = false

            switch result {
            case .success(let user):
                self.statusMessage.value = "欢迎, (user.username)!"
                self.loginSuccess.value = user
            case .failure(let error):
                self.statusMessage.value = error.localizedDescription
            }
        }
    }
}

为了实现数据绑定,我们创建一个简单的 Observable 类。

codeSwift

// Utils/Observable.swift
class Observable<T> {
    var value: T {
        didSet {
            listener?(value)
        }
    }

    private var listener: ((T) -> Void)?

    init(_ value: T) {
        self.value = value
    }

    func bind(listener: @escaping (T) -> Void) {
        self.listener = listener
        listener(value)
    }
}

最后是 LoginViewController,它通过绑定来响应 LoginViewModel 的变化。

codeSwift

// Views/LoginViewController.swift
import UIKit

class LoginViewController: UIViewController {

    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var statusLabel: UILabel!
    @IBOutlet weak var loginButton: UIButton!

    private let viewModel = LoginViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }

    private func bindViewModel() {
        usernameTextField.addTarget(self, action: #selector(usernameDidChange), for: .editingChanged)
        passwordTextField.addTarget(self, action: #selector(passwordDidChange), for: .editingChanged)

        viewModel.isLoading.bind { [weak self] isLoading in
            self?.loginButton.isEnabled = !isLoading
            if isLoading {
                self?.loginButton.setTitle("登录中...", for: .disabled)
            } else {
                self?.loginButton.setTitle("登录", for: .normal)
            }
        }

        viewModel.statusMessage.bind { [weak self] message in
            self?.statusLabel.text = message
        }

        viewModel.loginSuccess.bind { user in
            if user != nil {
                // 导航到主界面等操作...
            }
        }
    }

    @objc private func usernameDidChange(_ textField: UITextField) {
        viewModel.username.value = textField.text
    }

    @objc private func passwordDidChange(_ textField: UITextField) {
        viewModel.password.value = textField.text
    }

    @IBAction func loginButtonTapped(_ sender: UIButton) {
        viewModel.login()
    }
}```

**分析**:在 MVVM 中,`LoginViewController` 几乎不包含任何逻辑它只是将 UI 事件(如文本输入按钮点击)传递给 `LoginViewModel`,并绑定 `LoginViewModel` 暴露的属性来更新 UI`LoginViewModel` 完全独立于 UI,这使得它非常容易进行单元测试

总结

特性MVCMVPMVVM
核心思想控制器作为视图和模型的协调者Presenter 处理业务逻辑,视图被动更新ViewModel 暴露数据和命令,通过数据绑定更新视图
耦合度视图和控制器紧密耦合视图和 Presenter 通过协议解耦视图和 ViewModel 通过数据绑定解耦,耦合度最低
测试性难以对控制器进行单元测试Presenter 易于单元测试ViewModel 易于单元测试
主要问题容易导致“胖控制器”Presenter 和 View 之间需要大量的接口定义和手动调用数据绑定机制有学习成本,调试可能相对复杂
适用场景简单的小型项目或快速原型开发对测试性有一定要求,且不希望引入响应式框架的复杂项目复杂、需要长期维护的大型项目,尤其适合与 SwiftUI 或响应式框架(RxSwift/Combine)结合使用