移动应用App的分层架构

2016-03-21

在iOS上使用MVC会让人感觉不可思议?是否考虑转变成MVVM?也许你听说过VIPER,但是你不确定哪种架构才值得拥有?
继续阅读下面的文章,你将会找到以上涉及问题的答案,如果你不会随意抱怨在文章中的阐述的个人观点。
你将要构建你的知识关于在iOS环境中的架构模式。我们将简要的回顾一些流行的,并且比较它们在理论与实践中通过一些例子。如果你需要了解它们中任意一个的更多的细节,请按照链接方式打开。
掌握设计模式可能让人着迷,因此要深思:你可能在阅读这篇文章之前,问自己更多的问题,像下面这些:

  • 谁应该对自己发起发起网络请求:是一个模型还是控制器?
  • 怎样把模型传递给一个新视图的视图模型?
  • 谁创建一个新的VIPER模块:Router 还是 Presenter?

为什么要重视选择架构?

因为如果你没有选择一个架构,有一天,调试一个拥有几十个不同的东西的大类,你将会发现你自己很难找到并且修复这些bugs在你的类中。显然,很难把这些类当做一个整体,这样,你将总是遗漏一些重要的细节。如果你已经处于这样的情景在你的应用程序中,它很有可能是这样的:

  • 这个类是 UIViewController 的子类。
  • 你的数据直接存储在UIViewControllerView中
  • 你的UIViews几乎什么也没有
  • 这个模型是一个无效的数据结构
  • 你的单元测试没有涉及到
    这可能会发生,尽管这个事实是你按照苹果官方指导实现的MVC模式,因此,你不要灰心,这个与苹果的MVC有些错误。但是我们将稍后陈述。

让我们定义一个好的架构特点:

  1. 严格的实体角色责任均衡分配;
  2. 可测试性通常是架构的首要特点(不要担心:它很容易适应架构);
  3. 使用方便以及维护成本极低。

为什么要分配?

当我们试图弄清楚事情如何工作的时候,我们的大脑会对我们的大脑有一个均衡的负荷。如果你想你发展的越来越好,你的大脑会适应复杂的理解,那么你就对了。但这种能力并没有线性扩展,很快到达瓶盖。因此,最简单的方法来击败复杂的是,在多个实体之间的责任遵循单一的责任原则。

为什么要可测试性?

这通常不是一个问题对于那些友好的单元测试,但是在增加一些新的功能或者重构一个类后会失败。这意味着这些开发者在运行时就会发现问题,这可能会发生在一个应用程序在用户的设备,并且修复需要一个星期的时间才能到达用户。

为什么要使用方便

这并不要求一个答案,但是值得提醒的是最好的代码就是没有代码,因此拥有很少的代码,就会拥有很少的错误。这意味着,写更少的代码的想法,不应该被解释成仅仅是懒惰的开发者,你不应该支持一个更聪明的解决方案,去忽略你看到的它的维护成本。

MV(X)环节

如今我们又很多的选择当我设计到架构模式的时候:

  • MVC
  • MVP
  • MVVM
  • VIPER

它们当中的前三个假定把应用程序的实体分成三类:

  • Models -代表着数据区域或者数据访问层,那些持有数据,像Person或PersonDataProvider类
  • Views -代表呈现层(GUI),对于iOS环境来说,以”UI”开头。
  • Controller/Presenter/ViewModel -模型和视图之间的粘合或介体,一般负责通过对用户的行为进行反应,并在模型中修改视图,然后从模型中更新视图

分割实体让我们理解:

  • 理解它们更好
  • 重用它们
  • 单独测试它们
    让我们先了解MV(X),最好回顾VIPER

MVC

如何使用它

在讨论苹果的MVC版本之前,让我们认识下传统的MVC。

在这个例子中,View是无状态的,一旦Model被改变,它完全有控制器提供。想想下web网页完全的重载,一旦你按下链接,将会导航到其他地方。尽管实现传统的MVC在iOS应用中是可能的,但是这是毫无意义的,因为架构的问题–三个实体紧耦合的,没一个实体都知道其他两个,这个大大降低了它们中每一个可重用性–这就是不要使用在自己的应用程序中。对于这个原因,我们忽略写这个典型的MVC例子。、

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

Apple’s MVC(期望的)


这个控制器是一个中介,在视图和模型之间,因此它们相互不了解,很少的可重用性是控制器,这通常是好的对于我们来说,由于我们需要有一个地方,存放哪些不适合放到模型的所有棘手的业务逻辑。
在理论上,它看起来很简单,但是你觉得有些问题,对不?你甚至听到其他人抱怨MVC是一个臃肿MVC。而且,视图控制器瘦身将成为一个重要的课题对于iOS开发者来说。为什么是这样的难道苹果仅仅是把传统的MVC改进了一点点吗?

Apple’s MVC(Reality)

真正的Cocoa MVC

Cocoa MVC鼓励你去写Massive View Controllers.因为他们设计到视图的什么周期,这很难说他们是分开的。尽管你仍然有能力去减少一些业务逻辑和数据交换对于Model,但是你还没太多的选择,当涉及到对于视图而减少工作。在绝大多数时间,视图控制器的责任是发送行为给控制器。这个视图控制器最终成为一个代理并且是数据源以及通常负责发起和取消网络请求…
你有多少次看见你的代码像这样:

1
2
3
4
5

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

userCell.configureWithUser(user)

一个cell视图直接配置在模型中,因此,违背了苹果MVC的指导,但是一直在发生,并且通常人们不知道这是错误的。如果严格按照MVC模式。你应该从控制器中配置这个cell,并且不能把Model放进View,但这将增长控制器
的容量。

Cocoa MVC 是合情合理的未经简略的成为重量级视图控制器.

这个问题可能不是很明显,直到它涉及到单元测试(想想,它在你的项目中)。因为您的视图控制器被紧密耦合的视图,它变得很难测试,因为你必须非常有创意的mocking视图和他们的生命周期,而写的视图控制器的代码,在这样一种方式,您的业务逻辑是尽可能分离从视图布局代码。
让我们来看看简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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;

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

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

在视图和控制器交互之间不容易进行单元测试

总之,这似乎看起来MVC是一个不好的模式选择。但是让我们阐述就它的特点在上文开始时定义的那些:

  • 分配 — 视图与模型实际上是分开的,但是视图与控制器是紧耦合的。
  • 可测性 — 由于分配的不够清楚,所有能测试的仅仅模型而已。
  • 易用性 — 相较于其他模式,它的代码量最小,而且基本每个人都很熟悉它,即便是没有太多经验开发者也能维护。

Cocoa MVC 是一个很好的选择模式,如果你不想投入大量的时间在你的架构中,并且你感觉到你高额的维护成本在你的极小工程中。

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

#MVP

保证了职责分配的Cocoa MVC

是不是看起来很像苹果的MVC模式,是的,确实如此,它是MVP。稍等…这个意思是说 Apple 的 MVC 实际上是 MVP 吗?不是的,回想一下,在 MVC 里面 View 和 Controller 是耦合紧密的,但是对于 MVP 里面的 Presenter 来讲,它完全不关注 ViewController 的生命周期,而且 View 也能被简单 mock 出来,所以在 Presenter 里面基本没什么布局相关的代码,它的职责只是通过数据和状态更新 View
如果我告诉你 UIViewController 在这里的角色其实是 View.
在 MVP 架构里面,UIViewController 的那些子类其实是属于 View 的,而不是 Presenter。这种区别提供了极好的可测性,但是这是用开发速度的代价换来的,因为你必须要手动的去创建数据和绑定事件,像下面这段代码中做的一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57


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的特点:

  • 分配 - 我们把大部分的职责都分配到了 Presenter 和 Model 里面,而 View 基本上不需要做什么(在上面的例子里面,Model 也什么都没做)。

  • 可测性 - 简直棒,我们可以通过 View 来测试大部分的业务逻辑。

  • 易用性 - 就我们上面那个简单的例子来讲,代码量差不多是 MVC 架构的两倍,但是 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 很像:

MVVM 架构把 ViewController 看做 View。
View 和 Model 之间没有紧耦合
另外,它还像 Supervising 版的 MVP 那样做了数据绑定,不过这次不是绑定 View 和 Model,而是绑定 View 和 ViewModel。

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

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

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

选一个基于 KVO 的绑定库,比如 RZDataBinding 或者 SwiftBond。
使用全量级的 函数式响应编程 框架,比如 ReactiveCocoa、RxSwift 或者 PromiseKit。
实际上,现在提到「MVVM」你应该就会想到 ReactiveCocoa,反过来也是一样。虽然我们可以通过简单的绑定来实现 MVVM 模式,但是 ReactiveCocoa(或者同类型的框架)会让你更大限度的去理解 MVVM。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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 比 MVP 里面负责的事情要更多一些。因为前者是通过 ViewModel 的数据绑定来更新自身状态的,而后者只是把所有的事件统统交给 Presenter 去处理就完了,自己本身并不负责更新。

  • 可测性 - 因为 ViewModel 对 View 是一无所知的,这样我们对它的测试就变得很简单。View 应该也是能够被测试的,但是可能因为它对 UIKit 的依赖,你会直接略过它。

  • 易用 - 在我们的例子里面,它的代码量基本跟 MVP 持平,但是在实际的应用当中 MVVM 会更简洁一些。因为在 MVP 下你必须要把 View 的所有事件都交给 Presenter 去处理,而且需要手动的去更新 View 的状态;而在 MVVM 下,你只需要用绑定就可以解决。
    MVVM 真的很有魅力,因为它不仅结合了上述几种框架的优点,还不需要你为视图的更新去写额外的代码(因为在 View 上已经做了数据绑定),另外它在可测性上的表现也依然很棒。

VIPER

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

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

Interactor(交互器) - 包括数据(Entities)或者网络相关的业务逻辑。比如创建新的 entities 或者从服务器上获取数据;要实现这些功能,你可能会用到一些服务和管理(Services and Managers):这些可能会被误以为成是外部依赖东西,但是它们就是 VIPER 的 Interactor 模块。
Presenter(展示器) - 包括 UI(but UIKit independent)相关的业务逻辑,可以调用 Interactor 中的方法。
Entities(实体) - 纯粹的数据对象。不包括数据访问层,因为这是 Interactor 的职责。
Router(路由) - 负责 VIPER 模块之间的转场
实际上 VIPER 模块可以只是一个页面(screen),也可以是你应用里整个的用户使用流程(the whole user story)- 比如说「验证」这个功能,它可以只是一个页面,也可以是连续相关的一组页面。你的每个「乐高积木」想要有多大,都是你自己来决定的。

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

  • Model(数据交互)的逻辑被转移到了 Interactor 里面,Entities 只是一个什么都不用做的数据结构体。
  • Controller/Presenter/ViewModel 的职责里面,只有 UI 的展示功能被转移到了 * Presenter 里面。Presenter 不具备直接更改数据的能力。
  • VIPER 是第一个把导航的职责单独划分出来的架构模式,负责导航的就是 Router 层。
    如何正确的使用导航(doing routing)对于 iOS 应用开发来说是一个挑战,MV(X) 系列的架构完全就没有意识到(所以也不用处理)这个问题。

下面的这个列子并没有涉及到导航和 VIPER 模块间的转场,同样上面 MV(X) 系列架构里面也都没有涉及。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83

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 = presente

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

  • 划分 - 毫无疑问的,VIPER 在职责划分方面是做的最好的。
  • 可测性 - 理所当然的,职责划分的越好,测试起来就越容易
  • 易用 - 最后,你可能已经猜到了,上面两点好处都是用维护性的代价换来的。一个小小的任务,可能就需要你为各种类写大量的接口。

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

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

结论

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

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

参考资料