【译】iOS 架构模式--浅析MVC, MVP, MVVM 和 VIPER

作者:Bohdan Orlov 原文地址:https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52

在iOS开发过程中你是否对MVC的使用感觉很别扭?你是否对转向MVVM有疑惑?你听说过VIPER,但不清楚这个东西是否值得一试。

接着读下去,你会找到上面这些问题的答案。如果读完仍不能解惑,欢迎到评论区捶我。

接下来你将在iOS环境下构建关于架构模式的知识体系。我们将简要构建一些经典的例子,并在理论和实践上进行比较他们的不同。如果你需要更多关于任何一个特定的细节,请关注我。

掌握设计模式容易让人上瘾,所以要注意:阅读本文之前要问自己几个问题:

谁应该持有网络请求:Model还是Controller?

如何在一个新的View中向ViewModel传递Model

谁创建了一个新的VIPER模块:Router还是Presenter


为什么应该关心选用何种架构

因为如果你不这么做的话,总有一天,等这个类庞大到同时处理十几种事务,你会发现你根本无法从中找到对应代码并修改bug。当然,将这整个类了然于心是很难的,你会常常忘记一些重要的细节。如果你的程序已经处于这种状态了,那它很可能具有下面这些特征:

  • 这个类是UIViewController的子类
  • 你的数据直接在UIViewController中进行存储
  • 你的UIView基本什么都没做
  • 你的Model只是一个单纯的数据结构
  • 你的单元测试没有覆盖任何代码

即使你认为自己遵守了苹果的指导,并按照苹果推荐的MVC设计规范进行开发,但还是遇到了这些问题。不要担心,这是因为苹果的MVC本身就存在一些问题,我们稍后会再来讨论它。

让我们定义一下好的架构应该具备的特点: 1、平衡的分配实体和具体角色的职责 2、把可测试性放在第一位(通过合适的架构,这将很容易实现) 3、易用性和低维护成本

为什么要分配职责

职责的分配能让我们在尝试搞清楚事情如何运作这一过程中保持一个正常的负荷。你可能会认为你投入的精力越多你的大脑越能适应更加复杂的东西,这没错。但是这个能力是非线性的,而且会很快达到临界点。所以降低复杂性的最好的方式是,根据职责单一原则将它的功能(职责)分配到多个实体中。

为什么要可测试性

对于那些已经添加了单元测试的项目来说,当他们增加一个新的功能或者重构一个复杂的类时会由单元测试告知失败与否,这多让人很放心啊。同时这也意味着这些测试项将在运行时帮助开发者找到问题,而如果这些问题发生在用户设备上的,解决他们通常会花费一周。

为什么要易用性

这并不需要答案,但值得一提的是,最好的代码是那些从未写过的代码。所以,代码越少,bug就越少。这意味着,编写更少代码的愿望不应该仅仅由开发人员的懒惰来解释,而且你不应该为了采用更好的解决方案,而对其维护成本视而不见。

MV(X)的要素

如今我们又很多可选的架构方案:

  • MVC
  • MVP
  • MVVM
  • VIPER 前三项方案是把应用程序的实体分为三类:
  • Modes — 负责数据域和操作数据的数据访问层,例如’Person’类, ‘PersonDataProvider’类。
  • Views — 负责展示层(GUI),对于iOS环境就是指所有已‘UI’开头的类。
  • Controller/Presenter/ViewModel — 是ModelView的中介,通常的职责是通过响应用户在View的操作改变Model,然后根据Model的变化更新View

这些实体的分割帮助我们:

  • 更好的理解他们
  • 重用他们(通常是ViewModel
  • 单独测试他们

让我们开始讲解MV(X)模式,随后是VIPER

MVC

它原本是什么样的

在讨论苹果的MVC版本之前,让我们看一下传统的MVC模式:

这个模式下,View是无状态的。它只是简单的被Controller渲染当Model变化的时候。想一下Web页面,当你点一个链接尝试跳转时,整个页面都会重新渲染。尽管可以在iOS应用程序中实现传统的MVC,但由于架构问题,这并没有多大意义—— 所有三个实体都是紧密耦合的,每个实体都知道其他两个。这正好降低了他们的重用性,而这又是你在程序中不想看到的。因为这个原因,我们将跳过编写传统MVC代码的示例。

传统的MVC似乎不适合现代的iOS开发

苹果的MVC

预期效果

ControllerViewModel的中介,因此它俩互相不知道对方。可重用性最差的就是Controller,因为我们必须为复杂的业务逻辑提供一个位置,Model又不适合。

理论上,这看起来很简单,但是你总感觉有什么地方不对,是吧?你甚至听到人们解读MVC为Massive View Controller。也因此,视图控制器的简化成了iOS开发一个重要的课题。苹果只是采用传统的MVC并对其进行一些改进,为什么会出现这种情况呢?

现实情况

Cocoa MVC鼓励你编写大量的视图控制器,因为它们是视图生命周期的一部分,很难说它们是独立的。尽管你有能力转移一些业务逻辑和数据转换工作到Model中,当需要转移工作到View时你仍然没有太多选择,因为大多数情况View的职责就是发送行为到Controller。视图控制器最终将成为一个所有东西的委托、数据源、负责调度和取消网络请求,等等。

这种代码,你肯定见过很多少次了:

var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell
userCell.configureWithUser(user)

这个cell是View,直接通过Model进行配置,这明显违反了MVC的要求,但这种事情却经常发生,而且认为还不认为这是错的。如果你严格按照MVC的 做法,你应该在Controller里面配置cell,而不是将Model直接传递给View,但这样就会增加Controller的大小。

Cocoa MVC 被称为 Massive View Controller是多么合理啊。

这个问题还不是那么明显,直到提到单元测试(希望它存在于你的项目)。因为你的视图控制器跟View是紧耦合的,这将使得测试非常困难。所以你应该让你的业务逻辑和视图布局代码尽可能分割开来。

让我们看一个简单的例子:

import UIKit

struct Person { // Model
    let firstName: String
    let lastName: String
}

class GreetingViewController : UIViewController { // View + Controller
    var person: Person!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)
    }
    
    func didTapButton(button: UIButton) {
        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
        self.greetingLabel.text = greeting
        
    }
    // layout code goes here
}
// Assembling of MVC
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()

这样根本没法测试,对吧?我们可以把greeting的赋值移到一个新的类GreetingModel中,然后分开测试它。但是我们无法在不直接调用UIView相关方法(viewDidLoad, didTapButton)的情况下测试任何外在的逻辑,而如果这样做,这些方法就导致所有view的刷新,所以这本身就是一个不好的单元测试。

事实上,在一个模拟器上加载和测试UIViews表现正常,不代表它在别的设备依然这样。所以我建议测试时移除单元测试对“宿主程序”的依赖,而直接测试代码。

ViewController之间的交互行为无法通过Unit Tests进行。

根据上面的说法,Cocoa MVC是一个相当不好的架构方案。让我们再来根据文章开头定义的好架构应有的特性来评价下它:

  • 职责分离 — ViewModel是分离的,但ViewController是紧耦合关系。
  • 可测试性 — 由于不好的职责分离特性,只有Model层是可以测试的。
  • 易用性 — 这几种架构模式中它的代码量是最少的。每个人都很熟悉这种模式,即使是一个经验有限的开发者也可以很容易的维护这份代码。

如果你不打算投入很多事情在架构上,或者你感觉对于你们的小项目来说不值得投入过多维护成本,那你应该选择Cocoa MVC。

Cocoa MVC 是开发速度最快的一种架构。

MVP

这是不是更苹果的MVC非常像?确实是这样的,它的名字叫做MVP。等一下,这是不是意味着苹果的MVC事实上就是MVP?不。你可以再观察下这个结构,ViewController是紧耦合关系,作为MVP的中介者 — Presenter并没有管理视图控制器的生命周期,它里面也不含有布局代码,它的职责是根据数据的状态变化更新View,所以呢,View这一层就可以很简单的抽出来。

我会告诉你,UIViewController也是View

MVP模式下,UIViewController的子类实际上是Views而不是Presenters。这种区别提供了极好的可测试性,但这是以开发速度为代价的,因为你必须手动绑定数据和时间,就像这个例子:

import UIKit

struct Person { // Model
    let firstName: String
    let lastName: String
}

protocol GreetingView: class {
    func setGreeting(greeting: String)
}

protocol GreetingViewPresenter {
    init(view: GreetingView, person: Person)
    func showGreeting()
}

class GreetingPresenter : GreetingViewPresenter {
    unowned let view: GreetingView
    let person: Person
    required init(view: GreetingView, person: Person) {
        self.view = view
        self.person = person
    }
    func showGreeting() {
        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
        self.view.setGreeting(greeting)
    }
}

class GreetingViewController : UIViewController, GreetingView {
    var presenter: GreetingViewPresenter!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)
    }
    
    func didTapButton(button: UIButton) {
        self.presenter.showGreeting()
    }
    
    func setGreeting(greeting: String) {
        self.greetingLabel.text = greeting
    }
    
    // layout code goes here
}

关于装配(assembly)的重要说明

MVP是第一个揭露三层模型装配问题的模式。我们不想让ViewModel互通,因为在试图控制器(View)中执行装配操作是明显不对的,所以我们只能换个地方放装配的代码。例如,我们可以做一个应用范围的Router服务,它负责装配工作和ViewView的展示。这个问题的出现不仅要在MVP中解决,在以下的几个模式中也都要解决。

我们看下MVP的特性:

  • 职责分离 — 我们将大部分职责分配给了PresenterModel,而视图则什么都不需要做(上面的Model也是什么都不用做)
  • 可测试性 — 非常好,我们可以通过静态的View测试大多数逻辑。
  • 易用性 — 在我们上个简单示例中,代码量是MVC的两倍,但是它的逻辑是很清晰的。

在iOS中MVP模式意味着良好的可测试性和大量代码。

MVP

这是另一个MVP的样式 — 由视同控制器担当管理的MVP。这个变体中,ViewModel是直接绑定的,Presenter(担当管理的控制器)仍然处理着来自View的操作,并且能够改变View

但是通过上面的学习我们已经知道了,将ViewModel紧耦合处理,这种不明确的职责分离是很糟糕的。这与Cocoa桌面开发中的工作原理类似。

跟传统MVC一样,我找不到要为这个有缺陷的架构写示例的理由。

MVVM

最新而且是最好的一个MV(X)类型

MVVM是最新的MV(X)类型,希望它能解决我们之前讨论过的问题。

MVVM理论上看起来是很好的,ViewModel我们已经很熟悉了,它俩之间的中介者由View Model表示。

这和MVP很像:

  • MVVM也是把视图控制器当做View
  • ViewModel之间没有紧耦合关系

此外它的绑定逻辑很像MVP的监管版本;但是这次不是ViewModel,而是ViewView Model之间的绑定。

所以iOS当中的View Model到底是什么呢?它是UIKit独立于视图及其状态的表示。View Model调用Model执行更改,然后根据Model的更新再更新自己,因为我们绑定了ViewView Model,第一个模型将相应的更新。

绑定

我在MVP部分明确提到过绑定,这次让我们再来讨论一下。绑定出自于MacOS开发,在iOS中是没有的。我们虽然可以通过KVO和通知完成这一过程,但是这样的绑定方式并不方便。

如果我们不想自己实现的话,有两个选项可供参考:

如今当你听到“MVVM”,就应该想到ReactiveCocoa。因为它可以让你用很简单的绑定方式构建MVVM,几乎涵盖所有MVVM中的逻辑。

但是使用响应式框架会面临一个不好的现实:能力越大责任越大。使用reactive很容易将事情复杂化。也就是说,如果发生了一处错误,你需要花费很多时间去调试问题,可以简单看下响应式的调用堆栈。

在我们的示例中,响应式框架甚至KVO都是多余的,我们将使用showGreeting方法显式地要求View Model更新,并使用greetingDidChange回调函数的简单属性。

import UIKit

struct Person { // Model
    let firstName: String
    let lastName: String
}

protocol GreetingViewModelProtocol: class {
    var greeting: String? { get }
    var greetingDidChange: ((GreetingViewModelProtocol) -> ())? { get set } // function to call when greeting did change
    init(person: Person)
    func showGreeting()
}

class GreetingViewModel : GreetingViewModelProtocol {
    let person: Person
    var greeting: String? {
        didSet {
            self.greetingDidChange?(self)
        }
    }
    var greetingDidChange: ((GreetingViewModelProtocol) -> ())?
    required init(person: Person) {
        self.person = person
    }
    func showGreeting() {
        self.greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
    }
}

class GreetingViewController : UIViewController {
    var viewModel: GreetingViewModelProtocol! {
        didSet {
            self.viewModel.greetingDidChange = { [unowned self] viewModel in
                self.greetingLabel.text = viewModel.greeting
            }
        }
    }
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self.viewModel, action: "showGreeting", forControlEvents: .TouchUpInside)
    }
    // layout code goes here
}
// Assembling of MVVM

再次回来用这三个特征验证一下:

  • 职责分离 — 这在我们的小示例还无法体现,但是MVVM的View比着MVP的View有着更多的职责。因为前者通过View Model建立绑定更新它的状态,后者仅仅是把所有事件都交给Presenter,不更新自己。
  • 可测试性 — View Model是不知道View的,这可以让我们很容易的对它进行测试。View可能也需要测试,但因为它依赖UIKit,你可能想跳过它。
  • 易用性 — 它有河MVP模式相同的代码量,但是实际项目中,你不得不把所有事件通过View传给Presenter,然后还要手动更新View,比较而言,MVVM使用绑定将更加简洁。

MVVM是很有吸引力的,因为它包含了前面提到的优点,此外通过View层的绑定,也不需要额外的代码处理View更新。测试性也还不错。

VIPER

把乐高的搭建流程应用到iOS设计模式

VIPER是我们最后一个候选模式,有趣的一点是它不属于MV(X)类型。

目前为止,你必须同意职责的粒度是很重要的。VIPER在划分职责层面又做了一次迭代,它将项目划分成5层。

  • Interactor— 包含跟数据(Entities)和网络相关的业务逻辑,像是创建新的实例对象后者从服务器拉取数据。出于这些目的,你也可以使用Services和Mananger类完成功能,但这就不属于VIPER模块,而是外部依赖类。
  • Presenter — 包含UI相关的业务逻辑,调用Interactor中的方法。
  • Entities — 普通的数据对象,不是数据访问层,因为这是Interactor的责任。
  • Router — 负责VIPER模块之间的切换。

基本上,VIPER模块可以是一整屏内容,也可以是你应用中完整的用户行为 — 想一下授权行为,它可以在一个或者几个相关联的界面。“乐高”方块应该多小呢?这取决于你。

如果我们将它和MV(X)类比,会发现一些在职责划分上的区别:

  • Model(数据交互)逻辑转移到了包含Entities数据结构的Interactor中。
  • 只有Controller/Presenter/ViewModel这种UI表示层的职责转移到了Presenter中,不包含数据。
  • VIPER是第一个明确导航职责的模式,并通过Router解决这个问题。

在iOS应用中用一个优雅的方式处理跳转问题确实是一个挑战,MV(X)模式没有处理这个问题。

该示例不涉及模块之间的路由交互,因为MV(X)模式根本不涉及这些主题。

import UIKit

struct Person { // Entity (usually more complex e.g. NSManagedObject)
    let firstName: String
    let lastName: String
}

struct GreetingData { // Transport data structure (not Entity)
    let greeting: String
    let subject: String
}

protocol GreetingProvider {
    func provideGreetingData()
}

protocol GreetingOutput: class {
    func receiveGreetingData(greetingData: GreetingData)
}

class GreetingInteractor : GreetingProvider {
    weak var output: GreetingOutput!
    
    func provideGreetingData() {
        let person = Person(firstName: "David", lastName: "Blaine") // usually comes from data access layer
        let subject = person.firstName + " " + person.lastName
        let greeting = GreetingData(greeting: "Hello", subject: subject)
        self.output.receiveGreetingData(greeting)
    }
}

protocol GreetingViewEventHandler {
    func didTapShowGreetingButton()
}

protocol GreetingView: class {
    func setGreeting(greeting: String)
}

class GreetingPresenter : GreetingOutput, GreetingViewEventHandler {
    weak var view: GreetingView!
    var greetingProvider: GreetingProvider!
    
    func didTapShowGreetingButton() {
        self.greetingProvider.provideGreetingData()
    }
    
    func receiveGreetingData(greetingData: GreetingData) {
        let greeting = greetingData.greeting + " " + greetingData.subject
        self.view.setGreeting(greeting)
    }
}

class GreetingViewController : UIViewController, GreetingView {
    var eventHandler: GreetingViewEventHandler!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)
    }
    
    func didTapButton(button: UIButton) {
        self.eventHandler.didTapShowGreetingButton()
    }
    
    func setGreeting(greeting: String) {
        self.greetingLabel.text = greeting
    }
    
    // layout code goes here
}

// Assembling of VIPER module, without Router
let view = GreetingViewController()
let presenter = GreetingPresenter()
let interactor = GreetingInteractor()
view.eventHandler = presenter
presenter.view = view
presenter.greetingProvider = interactor

让我们再一次对比那几个特征:

  • 职责分离 — 毫无疑问,VIPER是职责分离做的最好的。
  • 可测试性 — 职责分离越好,可测试性当然也会更好
  • 易用性 — 你可能已经猜到了,上面两项意味着维护成本的提升。你必须写很多处理各个类之间交互的代码。

所有这个乐高模式到底怎么样呢?

当使用VIPER时,如果你感觉就像是通过乐高方块搭建帝国大厦,这就意味着出现了问题。不应该过早在你的应用中使用VIPER,你需要考虑简便性。有些人不注意简便性,直接使用VIPER,会有点大材小用。我猜测很多人是这么想的,他们的应用迟早都会发展到适用VIPER的复杂程度,所以早晚都会做的事,即使现在维护成本高也应该继续做下去。如果你就是这么想的,我推荐你试一下Generamba — 一个生成VIPER组件的工具。虽然对我个人来说,这感觉就像使用自动瞄准系统而不是简单的弹射。

总结

我们已经讲解了几个架构模式,希望你能解答曾经困扰你的问题。我敢肯定你也意识到了架构模式的选择没有最好这一说,它取决于你在特定环境下权衡利弊之后做的选择。

所以,在一个应用中出现混合一种混合的架构模式也是很常见的。例如,你一开始使用MVC,然后你发现有一个界面的逻辑变得很复杂,然后你转向了MVVM,但也是仅限于这个界面。你不必重构别的使用MVC的界面,因为它原本就是工作的好好的,而且这两个架构模式是很容易兼容的。

事情应该力求简单,不过不能过于简单 — 阿尔伯特·爱因斯坦