[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱

这篇浏览量突然就增多了吓一跳( ⊙ o ⊙ ) 想在前面写一句,我还是小弱,可能难免有翻译出问题的地方,有疑问的话请翻阅一下原稿,错误不足之处敬请指正,谢谢谢谢!


原作者Bohdan Orlov,原稿在这里:https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52#.62qdo7sak
在微博上看到很多人转发推荐这篇文章,我也一直想研究一下架构模式,就去看了,顺带翻译了。翻译到一半发现好长啊...不过还是坚持一口气写完了哈哈哈哈,开心O(∩_∩)O!


在iOS中使用MVC感到别扭吗?犹豫要换成MVVM吗?听说过VIPER,但是不知道它究竟有什么好处吗?
继续读下去,你就可以知道这些问题的答案,如果没有,可以在评论区随意抱怨喔。
接下来你将了解到iOS环境中架构模式的基本知识。我们会简要介绍一些常用的模式,并且用小例子来说明实际的用法。如果你需要知道更多的细节,可以点击相应的链接查看。
学习设计模式可是会上瘾的喔。看完这篇文章以后你可能会有比看之前更多的问题,比如:

  • 谁应该来拥有一个网络请求呢?模型还是控制器?
  • 我应该怎样把一个模型传入一个新视图的视图模型里呢?
  • 谁来新建一个VIPER模块,路由器还是表达者?


为什么要关心选择怎样的架构呢?

因为如果你不关心,有一天当你面对浩如烟海的类和bug的时候,你会束手无策冗不见治。你不可能记住你写的每个类,很容易忘记一些细节。也许你已经处于这种状况下了,比如说你很可能:

  • 写了一个UIViewController的子类
  • 你把数据直接存在UIViewController里了
  • 你的UIView基本没做任何事
  • 你的模型是一个死板的数据结构
  • 你的单元测试什么都没干

这些都是有可能发生的,就算你遵守了苹果的MVC模式指导,仍然可能会有这些情况,很正常,不要太难过喔。这是苹果MVC自己存在的问题,我们一会儿会说到。
首先来看一个优秀的架构应该是怎样的:

  1. 每个实体都有严格的角色确定,他们的任务分配是很明确而均匀的。
  2. 有了第一条,可测性一般也就有啦。
  3. 易于使用,易于维护。
为什么要分配?

分配使我们的大脑在想事情的时候任务量比较均衡。如果你觉得脑子越用越聪明,越能应对复杂的事物,你是对的。但是这样用很容易就会到达极限。对待复杂的事情,一个更容易的办法是把责任分给几个不同的实体,每个实体负责一小块它们自己要干的事情。

为什么要可测?

对于已经对单元测试感恩戴德的程序员来说,他们一定不会问这个问题。虽然单元测试让他们的app在添加某个新功能时崩溃掉,但至少这时崩溃比在用户的机器上崩溃要好,如果在运行期查找修复这样的错误,可能要花费长达一周的时间。

为什么要易用?

小知识:最好的代码是什么?是从来没写过的代码。因为代码越少Bug越少呀哈哈哈(好冷...)。不过这也说明,程序员不是因为懒才去琢磨怎么写更好的代码的。而且记得永远不要忽略维护代码的成本。

MV(X)基础

当前我们有很多设计模式可以选择:

  • MVC
  • MVP
  • MVVM
  • VIPER

前三个都是假设把app的各种实体归为三类:

  • 模型:负责数据层,处理和提供数据。比如“Person”和“PersonDataProvider”。
  • 视图:负责展示层(GUI)。比如iOS环境下各种以“UI”开头的类。
  • 控制器/表达者/视图模型:模型与视图间的沟通桥梁,一般负责根据用户的行为修改模型,再根据模型的变化更新视图。

这种分类使得我们可以:

  • 更好的理解它们
  • 复用它们
  • 独立地测试它们

现在先从MV(X)开始介绍,之后是VIPER。

MVC — 从前的它是怎样的?

在说苹果的MVC之前先来看一下传统意义上的MVC:

[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第1张图片

在这里,视图是无状态的。只是在模型改变的时候,控制器将视图做一个简单呈现。就好像你按下一个网页链接,然后网页被刷新一样。虽然在iOS应用中实现这种传统的MVC是可能的,但是并没有太大意义—因为三个部分耦合得过于紧密,每个部分都知道另两个部分的存在。这极大地降低了它们的复用性,这是我们在应用中不想看到的。因此,我们这里就不为它举例了。

传统MVC模式在iOS开发中不适用。


苹果的MVC—美好的理想

[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第2张图片

控制器是模型和视图的中介,而模型和视图互相不知道彼此的存在。最不具复用性的是控制器,不过这一点我们可以接受,因为我们必须有这样一个地方来存放不能放在模型里的复杂逻辑。
理论上看起来非常直接,但是你会觉得有点不对劲。为什么呢?你有时可能听到人们戏称MVC为巨无霸视图控制器(Massive View Controller)。而且,“为视图控制器减负”成为了iOS开发者的一个重要话题。看起来苹果只是把传统MVC模式改进了一小下,为什么会发生这样的情况呢?

苹果的MVC — 残酷的现实

Cocoa MVC模式促使你把视图控制器越写越大,因为在视图的生命周期中它们是如此的水乳交融密不可分。即使你可以把一些逻辑和数据转换卸载给模型,当你想卸载给视图一点东西的时候,你会发现似乎没有什么可以做的,大多数情况下视图负责传递行为给控制器。所以最终视图控制器变成了一个全世界的数据源和代理大管家,还经常负责分发网络请求以及各种乱七八糟的操作。你肯定写过无数次这样的代码:

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

这里的cell就是视图,它是直接由数据配置的,MVC原则遭到了破坏,但是人们一直都这样写而且并不觉得有什么不对。如果你严格遵守MVC,你应该通过控制器来配置cell,而不是把模型传给视图,这样做会让你的控制器写得更加冗长。

所以说,Cocoa MVC被戏称为巨无霸试图控制器(Massive View Controller)是不无道理的。

当遇到单元测试的时候,问题就更加明显了。鉴于视图和控制器是高度耦合的,你很难去做测试。因为你必须非常小心地去把复杂逻辑和视图显示的代码分开,来模拟视图的生命周期。

举个栗子:


[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第3张图片
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集成可以在展示视图控制器的时候进行。

这段代码看起来可测性不是太好,对吧?我们可以把创建greeting移动到一个新GreetingModel类里然后单独测试它,但是我们测试不了任何的展示相关的逻辑。因为这需要在GreetingViewController里直接调用UIView相关的方法(viewDidLoad, didTapButton),这可能会引起加载所有的view,而这不是单元测试的初衷。
事实上,在一个模拟器(比如iPhone4S)上加载测试UIView并不能保证它在别的设备上也好用,所以我建议,在模拟器没有运行你的app的情况下,把“Host Application”从你的单元测试配置目标中移除后再进行测试。

视图和控制器之间的交互在单元测试中并不能得到有效的测试。

这样看来,Cocoa MVC似乎是个不怎么样的模式。现在让我们从刚才提到的好的设计模式的三个特点的角度来考察一下它:

  • 分配 — 视图和模型是分开的,但是视图和控制器是耦合的。
  • 可测性 — 鉴于分配实现得不好,你恐怕只能测试你的模型。
  • 易用性 — 对比其他模式来说,代码量是最少的。并且,大家都很熟悉它,即便是不太有经验的程序员也可以轻松维护它。

如果你没有太多时间来打磨精修你的结构,或者你觉得对于你的工程规模,其他的模式维护成本过高,那么你应该选择Cocoa MVC。

就开发速度而言,Cocoa MVC是最好的架构模式。


MVP — 实现Cocoa MVC 的理想

[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第4张图片

是不是看上去和苹果MVC一模一样?对没错就是的。它的名字叫做MVP(Passive View variant)所以这是说苹果的MVC实际上是MVP吗?并不是。不同于MVC的视图和控制器的耦合,在MVP中的中介—表达者(Presenter),和试图控制器的生命周期没有任何关系,并且视图可以轻易地被模拟,因此提出者里不用写任何和布局(layout)有关的代码,它负责视图的数据和状态更新。
如果我告诉你,UIViewController其实就是View,你会不会爆炸?


对于MVP,UIViewController的子类实际上是视图而不是表达者。不要小看这个区别,它极大提升了可测性,虽然是以牺牲一定的开发速度为代价,因为你需要手动管理数据和时间之间的关系,就像下面这个例子一样:

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是第一个显现出集成问题的模式,因为这里有三个互相分离的层。我们不想让视图知道模型的存在,所以不能在视图控制器(即视图)里进行集成,只能在其他地方做。比如说,我们可以做一个整个app范围内通用的“路由器”服务,来负责集成以及View-to-View之间的交流和表达。集成的问题不是只有MVP有,在之后介绍的所有模式里都会有这个问题。

现在看一下MVP的特点:

  • 分配 — 我们把大部分事情分给了表达者和模型去做,视图则非常简单。
  • 可测性 — 非常棒,因为视图的设计很简单,我们可以去测试大部分的逻辑。
  • 易用性 — 在上面这个已经极其简单的例子中,代码量也达到了MVC模式的两倍。但是MVP的思想表达的很清楚。

iOS中的MVP代表了很好的可测性和长长的代码。


MVP — 加上数据绑定

还有另一种别有风味的MVP—监控控制器MVP。与上面相比的变化包括:直接将视图和模型进行绑定,而表达者(即监控控制器)依然负责处理视图的行为,并且可以改变视图。

[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第5张图片

但是我们也已经提到,这样模糊的责任划分是不对的,就像把视图和模型耦合起来一样,这和Cocoa桌面开发的情形很类似。
我刚才没有为传统MVC写例子。同样对于这个模式我也看不出举例的意义何在。

MVVM — MV(X)家族最新最先进的成员

MVVM是MV(X)家族的最新成员,我们希望它的出现可以解决之前MV(X)的一些问题。
理论上MVVM(Model-View-ViewModel)模型看上去棒极了。视图和模型我们都很熟悉了,这里的媒介改由View Model充当。

[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第6张图片

它和MVP很相似:

  • MVVM也把view controller当做视图。
  • 视图和模型之间没有耦合。

另外,它也像监控MVP那样做绑定,但是这次不是在视图和模型之间,是在视图和视图模型之间。

所以iOS里的视图模型到底是什么呀?大概来说,它是UIKit对视图极其状态的独立的代表。视图模型促使模型的更新,并且由于视图和视图模型之间有绑定,视图也会随之更新。

关于绑定

之前在MVP部分我简要提到绑定了,这里我们再继续讨论一下。
绑定是在OS X的开发中出现的,但是iOS的工具盒里并没有。虽然我们有KVO和通知机制,但是没有绑定方便。
那么既然我们没法自己写,我们有两个选择:

  • 基于KVO的库,比如RZDataBinding或SwiftBond
  • 全方位的功能反应性编程大野兽(…)比如ReactiveCocoa,RxSwift或者PromiseKit

事实上,现在如果你听到“MVVM”,你就会想到ReativeCocoa,反之亦然。虽然用简单的绑定实现MVVM是可行的,ReactiveCocoa是让你实现MVVM大部分功能的首选。
然而有一个关于反应性框架的事实是,它们的强大功能需要被有责任有能力地使用。使用“反应性”是很容易造成混乱的,如果哪个地方搞错了,你可能需要花非常长的时间debug。看一下下面的调用栈:


[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第7张图片

在我们简单的例子中,FRF框架或者KVO都属于杀鸡用牛刀了。我们直接通过调用showGreeting方法和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
let model = Person(firstName: "David", lastName: "Blaine")
let viewModel = GreetingViewModel(person: model)
let view = GreetingViewController()
view.viewModel = viewModel

又到了三特点分级打分时间:

  • 分配—在我们的小例子中不明显,事实上在MVVM中,视图比MVP中的视图有更多的责任担当。因为MVVM的视图,它的状态更新是由视图模型设置绑定实现的,而MVP中的视图只是把事件传递给表达者,自己不做更新。
  • 可测性—视图模型不知道视图,这使得我们可以方便地检测它。视图也可以被检测,不过它是依赖于UIKit的,也许你会想跳过这一步。
  • 易用性—在我们的例子中它与MVP拥有相同的代码量,但是在实际的app中,MVP的代码量更多,因为你需要手动把事件从视图传递到表达者,而MVVM可以用捆绑做到。

MVVM很有吸引力,因为它结合了之前模型的优点,并且不需要为视图的更新增加冗长的代码,并且可测性也还不错。


VIPER — 像搭乐高玩具一样设计iOS应用

VIPER是我们的最后一位选手,它不是MV(X)的成员所以更有趣喔。
你现在应该明白责任的明确划分的好处了。VIPER在划分责任上更上一层楼,现在我们有五层了:


[翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱_第8张图片

来认识一下这些小伙伴:

  • 交互器(Interactor)— 包含于数据(实体)或网络有关的逻辑,比如新建一个实体实例或者从服务器获取他们。实现这种目的时你可能需要一些不属于VIPER模块的Services或者Managers,它们作为外在的依赖者出现。
  • 表达者(Presenter)— 包含与UI相关(但与UIKit独立)的逻辑,调用交互器上的方法。
  • 实体(Entities)— 单纯的数据对象,但是并非数据获取层,因为那是交互器的功能。
  • 路由器(Router)-- 负责VIPER模块间的连接。

与MV(X)相比,我们可以看到责任分配上的几点不同:

  • 模型(数据交互)逻辑放到了交互器上,实体是单纯的数据结构。
  • 表达者只有展示UI的责任,而没有交换数据的功能。
  • VIPER是第一个清晰划分出导航责任的,这部分由路由器完成。

用合适的方法完成路由转发是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
interactor.output = presenter

最后一次三特点评分时间啦:

  • 分配—VIPER无疑是分配的冠军。
  • 可测性—分配好嘛,当然可测性也好啦。
  • 易用性 — 以上两点的优异表现,背后是易于维护性的牺牲。你需要写很多分工明确的类和接口。
那么现在问题来了,到底跟乐高有什么关系?

当你用VIPER的时候,你可能感觉就像用乐高积木搭帝国大厦,这是就要注意了,你大概做的有问题。也许现在使用VIPER对你的工程来说太早了,也许你可以考虑用更简单的模式。有些人忽略这些一味蛮干,我想他们以为即便现在的维护和开发成本很高,他们的应用在未来会受益于VIPER模式。如果你也这么觉得,我建议你去试试Generamba—一个建立VIPER骨架的工具。然而我觉得这就像你本可以拿弹弓去打麻雀,你却非要用带自动瞄准系统的玉米加农炮。

结论

我们介绍了几个架构模型,我希望看完以后你之前的一些困惑得到了解答。你也一定发现了没有全能的最优架构,选择架构是一个在不同需求间找平衡的过程。
因此,在一个应用中混合使用不同的架构是很正常的。比如:你从MVC开始,然后你发现某个界面用MVC不够高效,于是采用了MVVM,但只是对这个特定的界面。如果其他的界面用MVC用得好好的,没有必要把它们也重构成MVVC,因为它们可以很好地共存。

我们追求简单的做法,但不是为了简单而简单。 — 阿尔伯特.爱因斯坦

你可能感兴趣的:([翻译] iOS架构模式 — 揭开MVC, MVP, MVVM, VIPER的神秘面纱)