社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
更新:在这可以下载到NSLondon
里面的Sliders
代码
在开发过程中,是否觉得iOS的MVC软件架构很怪异?不知道如何切换到MVVM软件架构?有听说过VIPER架构,但是不知道是否值得一试?
继续往下读,你会找到上述问题的答案,如果找不到 —— 欢迎留言。
本文中,你将了解到关于iOS开发中会用到的软件架构知识。我们会通过理论分析及小练习来评估几个流行的iOS软件架构。如果希望详细了解某些特定知识点,可以点击附带的链接。
掌控软件架构,是会让人上瘾的,因此,请留意:看完本文后,你可能会比看之前提出更多问题,例如:
应该由哪个模块来处理网络请求?Model 还是 Controller?
如何将 Model 的数据「传入」一个 View 的 View Model?
应该由谁来创建新的 VIPER:Router 还是 Presenter?
如果不使用合适的软件架构,那么终会有一天,你需要调试一个非常庞大,包含了众多业务逻辑的类,那时你就会发现自己根本无从下手。通常来说,开发者无法记住一个庞大的类的所有业务逻辑,因此在分析过程中,往往会因为类的内容过多而忽略掉很多重要的细节。如果你的代码已经遇到这样的问题,那么通常会是这样:
UIViewController
UIViewController
中UIView
中几乎没有处理任何业务逻辑尽管你完全按照 Apple 的开发指导,并实现了 Apple的MVC架构,上述问题依然会出现。不过,问题并非出在你的身上,而是出在苹果的MVC架构上,后续我们会继续分析这个问题。
我们先来定义什么是好的软件架构:
良好的模块分工,可以大大简化我们对代码的理解难度。虽然通过大量的开发工作,可以训练我们的大脑去分析越来越复杂的逻辑,但是人总有极限,而且简单的逻辑更容易理解、不容易出错,所以,遵循单一职责原则,将复杂的业务逻辑分解。
对于深知单元测试好处的开发者来说,这并不是一个问题。单元测试可以大大地减少程序运行时才能发现的问题,这通常可以节省「用户反馈」->「Bug修复」->「新版本发布」->「用户安装新版本」这个耗时长达一周以上的过程。所以,程序的可测试性对于程序的稳定性是异常重要的。
毋庸置疑,最好的代码是还没被写出来的代码。因此,越少的代码,意味着越少的 bugs。这也意味着尽量以最少的代码实现相同的功能,并非意味着这个开发者懒惰,同时,也不能不看维护成本而盲目赞同一个看似聪明的方案。
现今,我们有几种比较流行的软件架构
前三种架构都将app分成三部分:
Person
和 PersonDataProvider
类ui
前缀的都属于 View将app从架构上分成三部分有利于我们:
我们先从 MV(X) 架构开始分析,然后再于 VIPER 进行对比。
如何使用
在开始讨论 Apple 版本的 MVC 架构前,我们先看看最初的 MVC 架构。
这个框架中,View 并非独立,在 Model 被修改时,View 只是简单地被 Controller 修改。其逻辑与网页更新过程类似:当用户输入网址并回车后,网页被重新加载,并显示远端服务器的内容。虽然我们可以在 iOS 中尝试实现传统 MVC 结构的 App,但由于此架构有一个明显的缺陷 —— 三个部分之间的耦合度非常高,每个部分都必须知道其他部分的具体接口与内容。这大大降低了代码的可重用性 —— 这不是大家希望在程序中使用的方式。因此,我们直接进入下一环节。
传统的 MVC 架构并不适用于现代的 iOS 开发。
预期效果
上图中可以看出,Controller 在 View 和 Model 中充当着「桥梁」的角色,View 和 Model 相互独立,不需要知道任何对方的细节。虽然 Controller 的复用性很差,不过也可以接受,毕竟很多复杂的业务逻辑是不能放在 Model 里,因此也只能放到 Controller 里的。
理论上整个框架简单明了,不过你是否已经发现一些端倪了?有人说,MVC 可以翻译为 笨重的 View Controller『译者注:原文是 Massive View Controller』。此外,view controller 的瘦身也成了iOS开发者的一大难题。为什么在经过 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 架构对你来说是一个不错的选择。
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 开发来说意味着良好的可测试性和大量的代码
包含「绑定」和「Hooters」『译者注:原文为「With Bindings and Hooters」,此处Hooters用词比较隐晦,未想到合适的翻译方式』
除了上述的 MVP 架构外,还有一种形式的 MVP 架构 —— Supervision Controller MVP. 这个 MVP 变体在 View 和 Model 间建立了直接的「绑定」关系,同时,Presenter(Supervising Controller)依旧负责 View 中 action 的响应以及 View 中数据的更新。
不过,此架构的耦合度比较糟糕,View 和 Model 紧密耦合。这有点类似于 Cocoa 桌面应用开发时遇到的状况。
鉴于此架构的缺陷,在此我们就不举实际代码例子了。
最好的 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 的更新。
在 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 写额外的代码。除此之外,可测试性也是良好的。
乐高建筑的理念移植到 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 架构进行分析:
在使用 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」』
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!