自鉴开发工作iOS 设计模式

iOS 软件架构 - MVC, MVP, MVVM 和 VIPE

2016-04-26  本文已影响1390人  ChamchamBen

更新:在这可以下载到NSLondon里面的Sliders代码

在开发过程中,是否觉得iOS的MVC软件架构很怪异?不知道如何切换到MVVM软件架构?有听说过VIPER架构,但是不知道是否值得一试?

继续往下读,你会找到上述问题的答案,如果找不到 —— 欢迎留言。

本文中,你将了解到关于iOS开发中会用到的软件架构知识。我们会通过理论分析及小练习来评估几个流行的iOS软件架构。如果希望详细了解某些特定知识点,可以点击附带的链接。

掌控软件架构,是会让人上瘾的,因此,请留意:看完本文后,你可能会比看之前提出更多问题,例如:

应该由哪个模块来处理网络请求?Model 还是 Controller?

如何将 Model 的数据「传入」一个 View 的 View Model?
应该由谁来创建新的 VIPER:Router 还是 Presenter?

谁会关心选用哪种软件架构?

如果不使用合适的软件架构,那么终会有一天,你需要调试一个非常庞大,包含了众多业务逻辑的类,那时你就会发现自己根本无从下手。通常来说,开发者无法记住一个庞大的类的所有业务逻辑,因此在分析过程中,往往会因为类的内容过多而忽略掉很多重要的细节。如果你的代码已经遇到这样的问题,那么通常会是这样:

尽管你完全按照 Apple 的开发指导,并实现了 Apple的MVC架构,上述问题依然会出现。不过,问题并非出在你的身上,而是出在苹果的MVC架构上,后续我们会继续分析这个问题。

我们先来定义什么是好的软件架构:

  1. 软件架构上具有明确的分工,各个模块的功能职责平衡分配,且明确。
  2. 良好的可测试性,通常良好的软件架构都具备良好的可测试性。
  3. 良好的易用性,维护成本低。

为什么需要模块分工?

良好的模块分工,可以大大简化我们对代码的理解难度。虽然通过大量的开发工作,可以训练我们的大脑去分析越来越复杂的逻辑,但是人总有极限,而且简单的逻辑更容易理解、不容易出错,所以,遵循单一职责原则,将复杂的业务逻辑分解。

为什么需要良好的可测试性?

对于深知单元测试好处的开发者来说,这并不是一个问题。单元测试可以大大地减少程序运行时才能发现的问题,这通常可以节省「用户反馈」->「Bug修复」->「新版本发布」->「用户安装新版本」这个耗时长达一周以上的过程。所以,程序的可测试性对于程序的稳定性是异常重要的。

为什么需要良好的易用性?

毋庸置疑,最好的代码是还没被写出来的代码。因此,越少的代码,意味着越少的 bugs。这也意味着尽量以最少的代码实现相同的功能,并非意味着这个开发者懒惰,同时,也不能不看维护成本而盲目赞同一个看似聪明的方案。

MV(X)基础

现今,我们有几种比较流行的软件架构

前三种架构都将app分成三部分:

将app从架构上分成三部分有利于我们:

我们先从 MV(X) 架构开始分析,然后再于 VIPER 进行对比。

MVC

如何使用

在开始讨论 Apple 版本的 MVC 架构前,我们先看看最初的 MVC 架构

这个框架中,View 并非独立,在 Model 被修改时,View 只是简单地被 Controller 修改。其逻辑与网页更新过程类似:当用户输入网址并回车后,网页被重新加载,并显示远端服务器的内容。虽然我们可以在 iOS 中尝试实现传统 MVC 结构的 App,但由于此架构有一个明显的缺陷 —— 三个部分之间的耦合度非常高,每个部分都必须知道其他部分的具体接口与内容。这大大降低了代码的可重用性 —— 这不是大家希望在程序中使用的方式。因此,我们直接进入下一环节。

传统的 MVC 架构并不适用于现代的 iOS 开发。

Apple 的 MVC

预期效果

上图中可以看出,Controller 在 View 和 Model 中充当着「桥梁」的角色,View 和 Model 相互独立,不需要知道任何对方的细节。虽然 Controller 的复用性很差,不过也可以接受,毕竟很多复杂的业务逻辑是不能放在 Model 里,因此也只能放到 Controller 里的。

理论上整个框架简单明了,不过你是否已经发现一些端倪了?有人说,MVC 可以翻译为 笨重的 View Controller『译者注:原文是 Massive View Controller』。此外,view controller 的瘦身也成了iOS开发者的一大难题。为什么在经过 Apple 改进的 MVC 架构中会出现这样的问题呢?

Apple 的 MVC

实际效果

在Cocoa MVC 中,由于 View 的生命周期,View 和 Controller 基本上绑定在一起,因此开发者也只能编写臃肿的 View Controllers 代码。虽然你已经把一部分业务逻辑和数据修改操作挪到了 Model 层,但如果想对 View 进行瘦身就没那么容易了,大部分时间 View 的职责是向 Controller 发送 action。最终,Controller 会是一个到处都是 delegate,一个臃肿的包含所有变量的 dataSouce,而且通常还需要兼顾异步网络通讯的操作,还有...凡事你想到的,基本都出现会在 Controller 中。

下面的代码是否似曾相似:

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

在此,本应该属于 View 的 cell 直接使用 Model进行配置,换言之, MVC 架构被打破了(MVC 中 View 与 Model 不应该直接通讯),但在iOS开发中,这种情况经常出现,而且开发者不会觉得这样有任何问题。如果开发者在开发过程中严格遵循 MVC 架构,那么他们需要额外设计代码,把 cell 配置挪到 controller 中,避免将 Model 传递到 View 中,这将会导致本来臃肿的 Controller 愈发臃肿。

Cocoa MVC 完全就是 Massive View Controller 的缩写。

这样的架构导致的问题在开发时可能不明显,但一旦到了单元测试阶段(希望你的工程有单元测试),问题将会暴露无遗。由于你工程中的 View controller 和 View 关系紧密,设计测试用例时必须遍历 View 显示时的所有情况,同时需要考虑 View 的生命周期,这使得高覆盖率的测试变得非常困难。

下面我们来看一个运行在 playground 下的例子:『译者注:在遵循原版的基础上,译者对代码进行了少许改善。运行环境「Xcode Version 7.3 (7D175)」』

import UIKit
import XCPlayground

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: #selector(GreetingViewController.didTapButton(_:)), forControlEvents: UIControlEvents.TouchUpInside)
        
        viewLayoutInitial()
    }
    
    func didTapButton(button : UIButton!) {
        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
        self.greetingLabel.text = greeting
    }

    func viewLayoutInitial() -> () {
        self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480)
        self.view.backgroundColor = UIColor(white: 1.0, alpha: 1.0);
        
        self.showGreetingButton.frame = CGRect(x: 10.0, y: 10.0, width: 90.0, height: 30.0)
        self.showGreetingButton.layer.cornerRadius = 6.0
        self.showGreetingButton.backgroundColor = UIColor.blueColor()
        
        self.greetingLabel.frame = CGRect(x: 10.0, y: 60.0, width: 200.0, height: 20.0)
        self.greetingLabel.textColor = UIColor.blueColor()
        self.greetingLabel.text = "Say hello to who?"
        
        self.view.addSubview(self.showGreetingButton);
        self.view.addSubview(self.greetingLabel);
    }
}

// Assembing of MVC
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()
view.person = model

XCPlaygroundPage.currentPage.liveView = view.view


MVC 架构存在于现在的 view controller 中

上述例子是否不大容易测试?虽然我们可以通过新建 GreetingModel 类并将 greeting 字符串的生成代码放到该类中来实现该部分代码的独立测试,但如果不调用 viewDidLoad didTapButton 方法,我们很多对 GreetingViewController 中 view 的显示逻辑(虽然上述例子没多少显示逻辑)进行测试。这也意味着在项目单元测试中,我们需要加载所有的 view,这对于单元测试来说是很糟糕的。

实际上,在模拟器(例如 iPhone 4S)上运行所有的 UIViews 并不能保证工程在其他设备(例如 iPad)上能正常运行,所以我建议在 Unit Test target 配置中删除 Host Application,直接对代码进行单元测试。

值得注意的是,View 和 Controller 之间的通讯基本上是不能进行单元测试的

综上所述,貌似 Cocoa MVC 是一个很差的架构。不过按照文章开头的论述,我们还是从三方面对其进行分析:

如果你不想花太多时间来选择软件架构,并且觉得稍高的维护工作量会对你的项目造成很大的影响,那么,Cocoa MVC 架构对你来说是一个不错的选择。

MVP

Cocoa MVC 希望成为的架构

是不是看着很像 Apple 的 MVC 架构? 但实际上此架构的名称是MVP(被动类型 View 的变体『译者注:原文为 Passive View variant』)。这是否意味着 Apple 的 MVC 实际上是 MVP ? 并非如此,在 Apple 的 MVC 中,View 和 Controller 是紧密耦合的,但在 MVP 中,Presenter 与 View/View Controller 完全解耦,Presenter中没有任何与 View 布局相关的代码,View 可以很方便地进行移植。即便这样,Presenter 依旧肩负着对 View 的数据更新和动作捕捉。

我要告诉你,UIViewController 实际上就是 View。

在 MVP 架构中,继承了 UIViewController 的子类实际上并非 Presenter ,而是单纯的 View 。这样的分类方式提供了极好的可测试性,与此同时,由于额外实现设计数据和动作之间的绑定,不可避免地会导致开发量的增加。具体例子如下『译者注:在遵循原版的基础上,译者对代码进行了少许改善。运行环境「Xcode Version 7.3 (7D175)」』:

//: Playground - noun: a place where people can play

import UIKit
import XCPlayground

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

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


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

class GreetingPresenter : GreetingViewPresenter {   //Presenter
    let view : GreetingViewController
    var person : Person
    
    required init(view: GreetingViewController, 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 {  //View
    var presenter : GreetingPresenter!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        self.showGreetingButton.addTarget(self, action: #selector(GreetingViewController.didTapButton(_:)), forControlEvents: UIControlEvents.TouchUpInside)
        
        viewLayoutInitial()
    }
    
    func didTapButton(button : UIButton) {
        self.presenter .showGreeting()
    }
    
    func setGreeting(greeting: String) {
        self.greetingLabel.text = greeting
    }
    
    func viewLayoutInitial() {
        self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480)
        self.view.backgroundColor = UIColor(white: 1.0, alpha: 1.0);
        
        self.showGreetingButton.frame = CGRect(x: 10.0, y: 10.0, width: 90.0, height: 30.0)
        self.showGreetingButton.layer.cornerRadius = 6.0
        self.showGreetingButton.backgroundColor = UIColor.blueColor()
        
        self.greetingLabel.frame = CGRect(x: 10.0, y: 60.0, width: 200.0, height: 20.0)
        self.greetingLabel.textColor = UIColor.blueColor()
        self.greetingLabel.text = "Say hello to who?"
        
        self.view.addSubview(self.showGreetingButton);
        self.view.addSubview(self.greetingLabel);
    }
}


//Assembling of MVP
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()
let presenter = GreetingPresenter(view: view, person: model)
view.presenter = presenter

XCPlaygroundPage.currentPage.liveView = view.view

关于「聚合」方式的重要说明
由于含有三个完全独立的模块,MVP 是我们讨论的架构中首个暴露出模块聚合问题的架构。虽然我们不希望 View 和 Model 之间有任何直接交互,但在 View 显示时进行模块间的聚合显然是不正确的,尽管我们必须在某个地方实现聚合。例如,我们可以创建一个具有完整App生命周期的「路由服务(Router service)」,专门负责模块间的聚合以及 View 与 View 之间的切换。「聚合」问题会在 MVP 及接下来的架构中一直存在并且不得不解决。

接下来我们分析一下 MVP 架构的特点:

MVP 架构对于 iOS 开发来说意味着良好的可测试性和大量的代码

MVP

包含「绑定」和「Hooters」『译者注:原文为「With Bindings and Hooters」,此处Hooters用词比较隐晦,未想到合适的翻译方式』

除了上述的 MVP 架构外,还有一种形式的 MVP 架构 —— Supervision Controller MVP. 这个 MVP 变体在 View 和 Model 间建立了直接的「绑定」关系,同时,Presenter(Supervising Controller)依旧负责 View 中 action 的响应以及 View 中数据的更新。

不过,此架构的耦合度比较糟糕,View 和 Model 紧密耦合。这有点类似于 Cocoa 桌面应用开发时遇到的状况。

鉴于此架构的缺陷,在此我们就不举实际代码例子了。

MVVM

最好的 MV(X) 架构,没有之一

MVVM是目前来说最新的 MV(X) 架构,希望它的出现能很好的解决上述架构所面临的问题。

从理论上分析,Model-View-ViewModel 的架构看起来非常完善。其中 View 和 Model 我们已经非常熟悉了, 而 View Model 则相当于两者之间的中间媒介。

MVVM 与 MVP 非常类似:

除此之外,MVVM 还使用了与 Supervising version MVP 架构类似的「绑定」机制;但是,这个「绑定」并非应用于 View 和 Model 之间,而是应用于 View 和 View Model 之间。

那么在 iOS 中,View Model 实际上是什么呢?从根本上说,View Model 是一个与 UIKit 无关的但负责控制 View 的显示和状态的模块。在运行过程中,View Model 监听着 Model 的变化,并根据 Model 的变化来更新自身对应的变量,同时,由于在 View 和 View Model 间设置了「绑定」,View Model 的变化也会「触发」 View 的更新。

绑定(Bindings)

在 MVP 架构分析的段落中,我们简短地介绍了「绑定」,在此,我们进行更深入的讨论。「绑定」来自于 OS X 开发,但在 iOS 中并没有引入相关的库。虽然在 iOS 中我们有 KVO 和 「通知(notifications)」,但就使用的便捷性来说,「绑定」还是更胜一筹。

鉴于我们不希望重复造轮子,对与「绑定」的应用,我们有下面两种选择:

实际上,如果你有听说过 MVVM —— 你会想到 ReactiveCocoa 和 vice versa. 虽然可以通过简单的「绑定」来实现 MVVM,但 ReactiveCocoa 能帮你更好地实现 MVVM.

不过,关于 reactive 框架,有一个残酷的事实:能力越大,责任越大『译者注:原文为「the great power comes with the great responsibility」,估计是出自漫威「蜘蛛侠」里 Uncle Ben 说的 「with great power comes great responsibility」』。在使用 reactive 框架时,很容易把事情弄得非常复杂。换言之,一个Bug的调试可能会耗费开发者大量的调试时间,看看下面的栈使用情况就能猜到一二了。

杀鸡焉用牛刀,对于我们简单的例子,FRF 和 KVO 都过于复杂,在此,我们可以直接在 ViewModel 中使用 showGreeting 函数和 greetingDidChange 回调函数来对 View 进行更新。例子如下:『译者注:在遵循原版的基础上,译者对代码进行了少许改善。运行环境「Xcode Version 7.3 (7D175)」』

import UIKit
import XCPlayground

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

protocol GreetingViewModelProtocol:class {
    var greeting:String? { get }
    var greetingDidChanged:((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.greetingDidChanged?(self)
        }
    }
    
    var greetingDidChanged: ((GreetingViewModelProtocol) -> ())?
    
    required init(person: Person) {
        self.person = person
        greeting = ""
    }
    
    @objc func showGreeting() {
        self.greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
    }
}

class GreetingViewController : UIViewController {
    var viewModel: GreetingViewModel! {
        didSet {
            self.viewModel.greetingDidChanged = { [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: #selector(self.viewModel.showGreeting), forControlEvents: UIControlEvents.TouchUpInside)
        
        viewLayoutInitial()
    }
    
    // layout code goes here
    func viewLayoutInitial() {
        self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480)
        self.view.backgroundColor = UIColor(white: 1.0, alpha: 1.0);
        
        self.showGreetingButton.frame = CGRect(x: 10.0, y: 10.0, width: 90.0, height: 30.0)
        self.showGreetingButton.layer.cornerRadius = 6.0
        self.showGreetingButton.backgroundColor = UIColor.blueColor()
        
        self.greetingLabel.frame = CGRect(x: 10.0, y: 60.0, width: 200.0, height: 20.0)
        self.greetingLabel.textColor = UIColor.blueColor()
        self.greetingLabel.text = "Say hello to who?"
        
        self.view.addSubview(self.showGreetingButton);
        self.view.addSubview(self.greetingLabel);
    }
}

// Assembling of MVVM
let model = Person(firstName: "David", lastName: "Blaine")
let viewModel = GreetingViewModel(person: model)
let view = GreetingViewController()
view.viewModel = viewModel

XCPlaygroundPage.currentPage.liveView = view.view

同样的,我们按照三个标准来对 MVVM 架构进行评判:

MVVM 架构非常诱人,它不仅包含了上述优点,同时由于「绑定」的机制,开发者不需要为更新 View 写额外的代码。除此之外,可测试性也是良好的。

VIPER

乐高建筑的理念移植到 iOS app 架构设计中

VIPER作为我们最后一个候选架构,同时也是最有趣的架构。

VIPER 在任务职责分层上是极好的,为了更好的进行职责分配,VIPER 增加了 Interation 层,至此,VIPER 总共有5个分层。

通常来说,VIPER 可以是一个页面,或者整个 app,至于具体怎么设计,完全取决于你。

如果与 MV(X) 的软件架构进行对比,我们会发现职能分配上的一些不同:

在 iOS中,处理 Router 是一件非常困难的事情,但 MV(X) 架构中不存在这个问题

下面的例子不包含 routing 和 interaction 模块。『译者注:在遵循原版的基础上,译者对代码进行了少许改善。运行环境「Xcode Version 7.3 (7D175)」』

import UIKit
import XCPlayground

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: #selector(GreetingViewController.didTapButton(_:)), forControlEvents: .TouchUpInside)
        
        self.viewLayoutInitial()
    }
    
    func didTapButton(button: UIButton) {
        self.eventHandler.didTapShowGreetingButton()
    }
    
    func setGreeting(greeting: String) {
        self.greetingLabel.text = greeting
    }
    
    // layout code goes here
    func viewLayoutInitial() {
        self.view.frame = CGRect(x: 0, y: 0, width: 320, height: 480)
        self.view.backgroundColor = UIColor(white: 1.0, alpha: 1.0);
        
        self.showGreetingButton.frame = CGRect(x: 10.0, y: 10.0, width: 90.0, height: 30.0)
        self.showGreetingButton.layer.cornerRadius = 6.0
        self.showGreetingButton.backgroundColor = UIColor.blueColor()
        
        self.greetingLabel.frame = CGRect(x: 10.0, y: 60.0, width: 200.0, height: 20.0)
        self.greetingLabel.textColor = UIColor.blueColor()
        self.greetingLabel.text = "Say hello to who?"
        
        self.view.addSubview(self.showGreetingButton);
        self.view.addSubview(self.greetingLabel);
    }
}
// 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

XCPlaygroundPage.currentPage.liveView = view.view

再次,我们通过三个维度对 VIPER 架构进行分析:

什么是 LEGO ?

在使用 VIPER 时,你可能会觉得自己在用 LEGO 方块拼凑一个帝国大厦,这或许是一个「存在问题」的信号。对于大部分开发者来说,VIPER 显得过于复杂以至于大家很容易就会放弃 VIPER 而寻找更简单的架构。对于一些人来说,他们可能会继续坚持使用 VIPER 架构,尽管这看起来像是在用大炮打麻雀『译者注:原文为「shooting out of cannon into sparrows」』。我觉得他们之所以愿意承受着非常高的维护代价而选择 VIPER,应该是他们觉得日后对他们的 app 会有很大的好处。如果你有相同的想法,不妨试试 Generamba —— 一个自动生成 VIPER 框架的插件。对于我个人来说,这就像使用一个带有全自动目标锁定系统的大炮,而不是一个简易便携的投石器『译者注:原文为「Although for me personally it feels like using an automated targeting system for cannon instead of simply thking a sling shot.」』

结论

在分析了上述几种常用软件框架后,希望你可以为心中的疑问找到答案,但毫无疑问的说,软件世界里没有「尚方宝剑」『译者注:原味为「silver bullet」,典故可参考WIKI』,选择哪种架构,很大程度上取决于你工程的具体情况。

因此,在一个 app 中使用多种软件架构其实是很正常的。例如你的项目开始时使用的是 MVC ,后面你可能发现个别复杂的页面使用 MVC 架构实现时会变得难以维护,此时你可能会使用 MVVM 架构对该界面代码进行重构。但并不需要修改其他使用 MVC 架构的运行良好的页面代码。

事情应该力求简单,不过不能过于简单 —— 爱因斯坦
『译者注:原文为「Everything Should Be Made as Simple as Possible, But Not Simpler —— Albert Einstein」』

上一篇下一篇

猜你喜欢

热点阅读