iOS 架构模式 - 简述 MVC, MVP, MVVM 和 VIPER (译)

2016-03-24 17:35:34 +08:00
 CodingNET

Make everything as simple as possible, but not simpler  —  Albert Einstein

把每件事,做简单到极致,但又不过于简单 - 阿尔伯特·爱因斯坦

在使用 iOS 的 MVC 时候感觉怪怪的?想要尝试下 MVVM ?之前听说过 VIPER ,但是又纠结是不是值得去学?

继续阅读,你就会知道上面问题的答案 - 如果读完了还是不知道的话,欢迎留言评论。

iOS 上面的架构模式你可能之前就了解过一些,接下来我们会帮你把它们进行一下梳理。我们先简要回顾一下目前比较主流的架构模式,分析比较一些他们的原理,并用一些小栗子来进行练习。如果你对其中的某一种比较感兴趣的话,我们也在文章里面给出了对应的链接。

对于设计模式的学习是一件容易上瘾的事情,所以先提醒你一下:在你读完这篇文章之后,可能会比读之前有更多的疑问,比如:

( MVC )谁来负责网络请求:是 Model 还是 Controller ?

( MVVM )我该怎么去把一个 Model 传递给一个新创建的 View 的 ViewModel ?

( VIPER )谁来负责创建 VIPER 模块:是 Router 还是 Presenter ?

为何要在意架构的选择呢?

因为如果你不在意的话,难保一天,你就需要去调试一个巨大无比又有着各种问题的类,然后你会发现在这个类里面,你完全就找不到也修复不了任何 bug 。一般来说,把这么大的一个类作为整体放在脑子里记着是一件非常困难的事情,你总是难免会忘掉一些比较重要的细节。如果你发现在你的应用里面已经开始出现这种状况了,那你很可能遇到过下面这类问题:

其实即便你遵循了 Apple 的设计规范,实现了 Apple 的 MVC 框架,也还是一样会遇到上面这些问题;所以也没什么好失落的。Apple 的 MVC 框架 有它自身的缺陷,不过这个我们后面再说。

让我们先来定义一下好的框架应该具有的特征:

  1. 用严格定义的角色,平衡的将职责 划分 给不同的实体。
  2. 可测性 通常取决于上面说的第一点(不用太担心,如果架构何时的话,做到这点并不难)。
  3. 易用 并且维护成本低。

为什么要划分?

当我们试图去理解事物的工作原理的时候,划分可以减轻我们的脑部压力。如果你觉得开发的越多,大脑就越能适应去处理复杂的工作,确实是这样。但是大脑的这种能力不是线性提高的,而且很快就会达到一个瓶颈。所以要处理复杂的事情,最好的办法还是在遵循 单一责任原则 的条件下,将它的职责划分到多个实体中去。

为什么要可测性?

对于那些对单元测试心存感激的人来说,应该不会有这方面的疑问:单元测试帮助他们测试出了新功能里面的错误,或者是帮他们找出了重构的一个复杂类里面的 bug 。这意味着这些单元测试帮助这些开发者们在程序运行之前就发现了问题,这些问题如果被忽视的话很可能会提交到用户的设备上去;而修复这些问题,又至少需要一周左右的时间( AppStore 审核)。

为什么要易用

这块没什么好说的,直说一点:最好的代码是那些从未被写出来的代码。代码写的越少,问题就越少;所以开发者想少写点代码并不一定就是因为他懒。还有,当你想用一个比较 聪明 的方法的时候,全完不要忽略了它的维护成本。

MV(X) 的基本要素

现在我们面对架构设计模式的时候有了很多选择:

首先前三种模式都是把所有的实体归类到了下面三种分类中的一种:

将实体进行分类之后我们可以:

让我从 MV(X) 系列开始讲起,最后讲 VIPER

MVC - 它原来的样子

在开始讨论 Apple 的 MVC 之前,我们先来看下 传统的 MVC

在这种架构下, View 是无状态的,在 Model 变化的时候它只是简单的被 Controller 重绘;就像网页一样,点击了一个新的链接,整个网页就重新加载。尽管这种架构可以在 iOS 应用里面实现,但是由于 MVC 的三种实体被紧密耦合着,每一种实体都和其他两种有着联系,所以即便是实现了也没有什么意义。这种紧耦合还戏剧性的减少了它们被重用的可能,这恐怕不是你想要在自己的应用里面看到的。综上,传统 MVC 的例子我觉得也没有必要去写了。

传统的 MVC 已经不适合当下的 iOS 开发了。

Apple 的 MVC

理想

View 和 Model 之间是相互独立的,它们只通过 Controller 来相互联系。有点恼人的是 Controller 是重用性最差的,因为我们一般不会把冗杂的业务逻辑放在 Model 里面,那就只能放在 Controller 里了。

理论上看这么做貌似挺简单的,但是你有没有觉得有点不对劲?你甚至听过有人把 MVC 叫做重控制器模式。另外 关于 ViewController 瘦身 已经成为 iOS 开发者们热议的话题了。为什么 Apple 要沿用只是做了一点点改进的传统 MVC 架构呢?

现实

Cocoa MVC 鼓励你去写重控制器是因为 View 的整个生命周期都需要它去管理, Controller 和 View 很难做到相互独立。虽然你可以把控制器里的一些业务逻辑和数据转换的工作交给 Model ,但是你再想把负担往 View 里面分摊的时候就没办法了;因为 View 的主要职责就只是讲用户的操作行为交给 Controller 去处理而已。于是 ViewController 最终就变成了所有东西的代理和数据源,甚至还负责网络请求的发起和取消,还有...剩下的你来讲。

像下面这种代码你应该不陌生吧:

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

Cell 作为一个 View 直接用 Model 来完成了自身的配置, MVC 的原则被打破了,这种情况一直存在,而且还没人觉得有什么问题。如果你是严格遵循 MVC 的话,你应该是在 ViewController 里面去配置 Cell ,而不是直接将 Model 丢给 Cell ,当然这样会让你的 ViewController 更重。

Cocoa MVC 被戏称为重控制器模式还是有原因的。

问题直到开始 单元测试(希望你的项目里面已经有了)之后才开始显现出来。 Controller 测试起来很困难,因为它和 View 耦合的太厉害,要测试它的话就需要频繁的去 mock View 和 View 的生命周期;而且按照这种架构去写控制器代码的话,业务逻辑的代码也会因为视图布局代码的原因而变得很散乱。

我们来看下面这段 playground 中的例子:

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()
view.person = model;

MVC 的组装,可以放在当前正在显示的 ViewController 里面

这段代码看起来不太好测试对吧?我们可以把 greeting 的生成方法放到一个新类 GreetingModel 里面去单独测试。但是我们如果不调用与 View 相关的方法的话 (viewDidLoad, didTapButton),就测试不到 GreetingViewController 里面任何的显示逻辑(虽然在上面这个例子里面,逻辑已经很少了);而调用的话就可能需要把所有的 View 都加载出来,这对单元测试来说太不利了。

实际上,在模拟器(比如 iPhone 4S )上运行并测试 View 的显示并不能保证在其他设备上(比如 iPad )也能良好运行。所以我建议把「 Host Application 」从你的单元测试配置项里移除掉,然后在不启动模拟器的情况下去跑你的单元测试。

View 和 Controller 之间的交互,并不能真正的被单元测试覆盖

综上所述, Cocoa MVC 貌似并不是一个很好的选择。但是我们还是评估一下他在各方面的表现(在文章开头有讲):

在这种情况下你可以选择 Cocoa MVC :你并不想在架构上花费太多的时间,而且你觉得对于你的小项目来说,花费更高的维护成本只是浪费而已。

如果你最看重的是开发速度,那么 Cocoa MVC 就是你最好的选择。

MVP - 保证了职责划分的( promises delivered ) Cocoa MVC

看起来确实很像 Apple 的 MVC 对吧?确实蛮像,它的名字是 MVP(被动变化的 View )。稍等...这个意思是说 Apple 的 MVC 实际上是 MVP 吗?不是的,回想一下,在 MVC 里面 View 和 Controller 是耦合紧密的,但是对于 MVP 里面的 Presenter 来讲,它完全不关注 ViewController 的生命周期,而且 View 也能被简单 mock 出来,所以在 Presenter 里面基本没什么布局相关的代码,它的职责只是通过数据和状态更新 View 。

如果我跟你讲 UIViewController 在这里的角色其实是 View 你感觉如何。

在 MVP 架构里面, UIViewController 的那些子类其实是属于 View 的,而不是 Presenter 。这种区别提供了极好的可测性,但是这是用开发速度的代价换来的,因为你必须要手动的去创建数据和绑定事件,像下面这段代码中做的一样:

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
}
// Assembling of MVP
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()
let presenter = GreetingPresenter(view: view, person: model)
view.presenter = presenter

关于组装方面的重要说明

MVP 架构拥有三个真正独立的分层,所以在组装的时候会有一些问题,而 MVP 也成了第一个披露了这种问题的架构。因为我们不想让 View 知道 Model 的信息,所以在当前的 ViewController (角色其实是 View )里面去进行组装肯定是不正确的,我们应该在另外的地方完成组装。比如,我们可以创建一个应用层( app-wide )的 Router 服务,让它来负责组装和 View-to-View 的转场。这个问题不仅在 MVP 中存在,在接下来要介绍的模式里面也都有这个问题。

让我们来看一下 MVP 在各方面的表现:

MVP 架构在 iOS 中意味着极好的可测性和巨大的代码量。

MVP - 添加了数据绑定的另一个版本

还存在着另一种的 MVP - Supervising Controller MVP 。这个版本的 MVP 包括了 View 和 Model 的直接绑定,与此同时 Presenter ( Supervising Controller )仍然继续处理 View 上的用户操作,控制 View 的显示变化。

但是我们之前讲过,模糊的职责划分是不好的事情,比如 View 和 Model 的紧耦合。这个道理在 Cocoa 桌面应用开发上面也是一样的。

就像传统 MVC 架构一样,我找不到有什么理由需要为这个有瑕疵的架构写一个例子。

MVVM - 是 MV(X) 系列架构里面最新兴的,也是最出色的

MVVM 架构是 MV(X) 里面最新的一个,让我们希望它在出现的时候已经考虑到了 MV(X) 模式之前所遇到的问题吧。

理论上来说, Model - View - ViewModel 看起来非常棒。 View 和 Model 我们已经都熟悉了,中间人的角色我们也熟悉了,但是在这里中间人的角色变成了 ViewModel 。

它跟 MVP 很像:

另外,它还像 Supervising 版的 MVP 那样做了数据绑定,不过这次不是绑定 View 和 Model ,而是绑定 View 和 ViewModel 。

那么, iOS 里面的 ViewModel 到底是个什么东西呢?本质上来讲,他是独立于 UIKit 的, View 和 View 的状态的一个呈现( representation )。 ViewModel 能主动调用对 Model 做更改,也能在 Model 更新的时候对自身进行调整,然后通过 View 和 ViewModel 之间的绑定,对 View 也进行对应的更新。

绑定

我在 MVP 的部分简单的提过这个内容,在这里让我们再延伸讨论一下。绑定这个概念源于 OS X 平台的开发,但是在 iOS 平台上面,我们并没有对应的开发工具。当然,我们也有 KVO 和 通知,但是用这些方式去做绑定不太方便。

那么,如果我们不想自己去写他们的话,下面提供了两个选择:

实际上,现在提到「 MVVM 」你应该就会想到 ReactiveCocoa ,反过来也是一样。虽然我们可以通过简单的绑定来实现 MVVM 模式,但是 ReactiveCocoa (或者同类型的框架)会让你更大限度的去理解 MVVM 。

响应式编程框架也有一点不好的地方,能力越大责任越大嘛。用响应式编程用得不好的话,很容易会把事情搞得一团糟。或者这么说,如果有什么地方出错了,你需要花费更多的时间去调试。看着下面这张调用堆栈图感受一下:

在接下来的这个小例子中,用响应式框架( FRF )或者 KVO 都显得有点大刀小用,所以我们用另一种方式:直接的调用 ViewModel 的 showGreeting 方法去更新自己(的 greeting 属性),(在 greeting 属性的 didSet 回调里面)用 greetingDidChange 闭包函数去更新 View 的显示。

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
let model = Person(firstName: "David", lastName: "Blaine")
let viewModel = GreetingViewModel(person: model)
let view = GreetingViewController()
view.viewModel = viewModel

然后,我们再回过头来对它各方面的表现做一个评价:

MVVM 真的很有魅力,因为它不仅结合了上述几种框架的优点,还不需要你为视图的更新去写额外的代码(因为在 View 上已经做了数据绑定),另外它在可测性上的表现也依然很棒。

VIPER - 把搭建乐高积木的经验应用到 iOS 应用的设计上

VIPER 是我们最后一个要介绍的框架,这个框架比较有趣的是它不属于任何一种 MV(X) 框架。

到目前为止,你可能觉得我们把职责划分成三层,这个颗粒度已经很不错了吧。现在 VIPER 从另一个角度对职责进行了划分,这次划分了 五层

实际上 VIPER 模块可以只是一个页面( screen ),也可以是你应用里整个的用户使用流程( the whole user story )- 比如说「验证」这个功能,它可以只是一个页面,也可以是连续相关的一组页面。你的每个「乐高积木」想要有多大,都是你自己来决定的。

如果我们把 VIPER 和 MV(X) 系列做一个对比的话,我们会发现它们在职责划分上面有下面的一些区别:

如何正确的使用导航( doing routing )对于 iOS 应用开发来说是一个挑战, MV(X) 系列的架构完全就没有意识到(所以也不用处理)这个问题。

下面的这个列子并没有涉及到导航和 VIPER 模块间的转场,同样上面 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
interactor.output = presenter

我们再来评价下它在各方面的表现:

那么,我们到底应该给「乐高」一个怎样的评价呢?

如果你在使用 VIPER 框架的时候有一种在用乐高积木搭建帝国大厦的感觉,那么你可能 正在犯错误;可能对于你负责的应用来说,还没有到使用 VIPER 的时候,你应该把一些事情考虑的再简单一些。总是有一些人忽视这个问题,继续扛着大炮去打小鸟。我觉得可能是因为他们相信,虽然目前来看维护成本高的不合常理,但是至少在将来他们的应用可以从 VIPER 架构上得到回报吧。如果你也跟他们的观点一样的话,那我建议你尝试一下 Generamba - 一个可以生成 VIPER 框架的工具。虽然对于我个人来讲,这感觉就像给大炮装上了一个自动瞄准系统,然后去做一件只用弹弓就能解决的事情。

结论

我们简单了解了几种架构模式,对于那些让你困惑的问题,我希望你已经找到了答案。但是毫无疑问,你应该已经意识到了,在选择架构模式这件问题上面,不存在什么 银色子弹,你需要做的就是具体情况具体分析,权衡利弊而已。

因此在同一个应用里面,即便有几种混合的架构模式也是很正常的一件事情。比如:开始的时候,你用的是 MVC 架构,后来你意识到有一个特殊的页面用 MVC 做的的话维护起来会相当的麻烦;这个时候你可以只针对这一个页面用 MVVM 模式去开发,对于之前那些用 MVC 就能正常工作的页面,你完全没有必要去重构它们,因为两种架构是完全可以和睦共存的。

更新:这里有个简短的 PPT

原文链接

5164 次点击
所在节点    iOS
12 条回复
ichanne
2016-03-24 18:06:04 +08:00
顶后再看!
LeoDev
2016-03-24 18:11:48 +08:00
毅力点赞,私以为喜欢那样用那样,好坏自己承担。
jackisnotspirate
2016-03-24 18:16:20 +08:00
there is a better version of viper: clean swift http://clean-swift.com/clean-swift-ios-architecture/
zsk425
2016-03-24 19:02:25 +08:00
看完还是不太清楚,有没有讲得更清楚的文章推荐?另外,关于测试,一直不知道怎么用到项目中来,有没有人可以举一些测试例子?希望有大牛指点
expkzb
2016-03-24 19:18:04 +08:00
昨天刚看到 RZDatabinding ,有点相见恨晚的感觉
squall7902
2016-03-24 19:37:03 +08:00
流弊
ifyour
2016-03-24 22:34:31 +08:00
收下我的 Star !
fhefh
2016-03-24 22:40:09 +08:00
mark~
Vanson
2016-03-25 00:05:07 +08:00
收下我的 Star !
so898
2016-03-25 01:13:04 +08:00
吃过 MVVM 的亏
当年别人写的代码, ViewModel 和 Model 高度绑定,然后 iOS 7 把 NSObject 默认的 Description 给用掉了,并且禁止复用,然后代码就炸了
上上下下找了一圈,花了一天多的时间才理完整个代码部分
更糟糕的是代码大量使用 KVO ,当时服务器接口设置又没有考虑到 Description 问题,所以最后只能放弃 Pod 对 KVO 框架的管理,把代码自定义
那次之后,我对于 MVVM 、 KVO 这种很方便的工具就一直处于极度不信任的状态,而且基本上不太愿意在项目中使用 Pod ,要求下面的程序员直接引入代码到项目,在使用时要求精简并修改之后,按照项目需求针对性使用
lzlizhi
2016-03-29 20:01:22 +08:00
完全没看懂
acumen
2016-10-16 19:04:15 +08:00
马克

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/266111

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX