iOS架构设计
作为iOS开发者应该听到过MVC,可能在考虑要不要转到MVVM,或者听说了VIPER这种高大上的,在想采用这种复杂的架构值不值?
本文试图回答以上问题,帮助大家对iOS架构有一个初步的了解。我们将通过一些简单的例子介绍架构的演进,他们的异同。
为什么要考虑架构选择的问题
因为开发时如果不采用架构,随着App复杂度的提高,势必出现一些巨大的类,在其中定位及修复bug都会变得越来越困难。代码组织很可能会是这样的:
- 那些巨大的类是UIViewController的子类
- 在UIViewController中操作数据
- UIView基本不干啥
- Model只是些数据结构,没有动作
- 单元测试并没有覆盖到什么
让人更加郁闷的是,你明明是按照Apple的推荐来组织代码的,用的就是Apple的MVC架构。不奇怪,Apple的MVC是有问题的。
好的架构需要什么特性
- 不同模块角色明晰,代码均衡分布于这些模块上
- 由第1点带来的可测试性
- 易用性,维护成本低
随着App复杂度的提高,终会达到大脑清晰思考的极限。解决之道就是拆分成多个组件,遵循single responsibility principle,每个组件只完成一个功能,而这个功能完全封装在这个组件中。
可测试性对于已经尝到单元测试甜头的开发者来说是理所当然的,尤其是在增加新特性或者重构之后发现测试通不过的时候。测试可以提前发现在运行时会出现的问题,设想这些问题会在用户使用时发生,而修复要在审核周期后才能上线。
易用性很好理解。我们说写的代码越少,bug就越少。所以追求代码量少,绝不是因为开发者懒。同时也要避免那些会提高维护成本的奇技淫巧。
常见架构
- MVC
- MVVM
- VIPER
前两者结构类似,都是把App的模块分成3个大类:
Models:负责数据或者操作数据的数据存取层。例如User或者UserDataProvider类。
Views:负责展示层(GUI)。对iOS来说,包括所有那些前缀是UI的东西。
Controller / ViewModel:Model,View之间的胶水或者中间人。对用户在View所做的操作进行响应,改变Model,同时当Model变化时,更新View。
把模块分开的好处:
- 更容易理解
- 利于重用,尤其是View和Model
- 便于隔离开进行测试
MVC
期望
Cocoa MVCController是沟通View和Model的中间人,View和Model之间相互是不知道的。其中重用性最低的是Controller,这并不是个问题,毕竟,总要有个地方放置业务逻辑。
这个结构看上去很容易理解。但现实的情况是View Controller会变的非常臃肿,给View Controller减肥成为开发者的一个重要课题。这是怎么发生的呢?
现实
Realistic Cocoa MVCCocoa MVC事实上鼓励你写臃肿的view controller,他们与view生命周期耦合的如此紧密,以致很难说他们是分开的。尽管你仍然可以把一些业务逻辑,数据转化迁移到Model,想把一些工作转移给view就没那么容易了。大多数时间,所有view的工作就是发送动作给controller。View controller最后成为各种代理和数据源的集中地,同时还要负责发起和取消网络请求,等等。
这是一段极其常见的代码:
var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell
userCell.configureWithUser(user)
在这里,cell属于view,但是直接由model来配置,MVC的原则被打破了,这种写法太常见了,人们都不觉得这里有什么问题。如果我们严格遵循MVC的原则,在controller中配置cell,不把Model传给view, 那就要往已经臃肿的controller里塞进更多代码。
如果不写单元测试,这个问题可能还不那么明显。由于controller与view的紧密耦合,测试变的很困难,因为不得不要创造性的模拟view及其生命周期。
来看一个简单的例子:
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: Selector(("didTapButton:")), for: .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: "John", lastName: "Smith")
let view = GreetingViewController()
view.person = model
这看上去不太好测试。我们可以把greeting的生成移到Model里,分开测试,而我们想要测试GreetingViewController中那些展示逻辑的话,就必须直接调用UIView相关的方法(viewDidLoad, didTapButton),这样的话,就需要加载所有的view,这对单元测试来说可不是好事。
View和controller之间的交互,恐怕就是无法用单元测试来测试。
到这里,看上去Cocoa MVC是一个糟糕的设计模式。让我们用前文提到的特性列表来评估一下:
- 分布:View和Model确实是分开的,但是view和controller是紧密耦合的。
- 可测试性:由于糟糕的分布,你可能只会测试Model。
- 易用:在所有模式中代码量是最少的。另外,对所有人来说都很熟悉,所以即使是生手也能维护。
如果你不打算花大量时间在架构上,Cocoa MVC将是你的选择,或者你觉得用一个较高维护成本的架构开发一个小项目是杀鸡用牛刀。
对于开发速度来说,Cocoa MVC是最好的设计模式。
MVVM
MVVM是MV(X)系列中最新的一种,希望之前MV(X)中存在的一些问题,他都考虑进去了。
理论上Model-View-ViewModel看上去很不错。View和Model我们已经很熟悉了,还包括中间人,由View Model表示。
MVVM- MVVM把view controller视作View。在这里,View Controller的子类实际上属于View,而不是中间人。
- View和Model之间没有紧密耦合。
- 中间人在这里表达为View Model。
另外,在view和view model之间存在binding。
那么在iOS现实中,view model是什么呢?他基本上是view及其状态的代表,并且是与UIKit无关的。View Model调用Model的变化,同时根据Model的更新,更新自己。由于view和view model之间存在binding,view也会相应更新。
Bindings
Mac OS直接就支持Binding,但是iOS并不支持。当然iOS支持KVO和notification,但是没有binding方便。
假设不想自己实现,我们有两个选项:
-
基于KVO的binding库,例如Swift Bond,RZDataBinding。
-
Functional Reactive Programming框架,例如ReactiveCocoa,RxSwift。
事实上,现在当谈到MVVM时,总是和ReactiveCocoa等联系在一起的,反之亦然。虽然可以通过简单的binding搭建MVVM,用ReactiveCocoa等可以充分发挥MVVM。
在我们简单的例子里,FRP框架甚至KVO都不需要。我们将显式的通过showGreeting
方法让view model更新,用一个简单的属性作为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: Selector(("showGreeting")), for: .touchUpInside)
}
// layout code goes here
}
// Assembling of MVVM
let model = Person(firstName: "John", lastName: "Smith")
let viewModel = GreetingViewModel(person: model)
let view = GreetingViewController()
view.viewModel = viewModel
我们再来评估一下特性:
-
分布:MVVM的view比MVP的view责任更多,他通过设置binding,根据view model更新状态。
-
可测试性:view model对view一无所知,所以测试比较容易。View或许也能测,但是因为依赖于UIKit,可能就想跳过了
-
易用性:如果采用binding,MVVM的代码量会大为减少
MVVM是很有吸引力的,他不仅保持了之前方案的优点,而且因为binding在view中的采用,view更新不需要额外的代码。与此同时,测试也比较便利。
VIPER
最后是VIPER,不属于MV(X)家族。
现在,你一定同意其职责的细分很优秀。VIPER在职责的细分上更进了一步,有了5层。
VIPER-
Interactor: 包含了与数据(Entity)或者网络相关的业务逻辑,例如创建数据的新实例,从服务器获取数据。对于那些任务,一般会使用一些Service或者Manager,这些一般不被认为是VIPER模块的组成部分,而认为是外部依赖。
-
Presenter: 包含与UI相关(同时与UIKit无关)的业务逻辑,会调用Interactor的方法。
-
Entities: 普通的数据对象,但不是数据存取层,因为那属于Interactor负责的。
-
Router: 负责VIPER各模块之间的转移。
VIPER模块既可以是单一屏幕,也可以是应用的整个user story,比方说用户认证,可以是一个屏幕也可以是多个相关的屏幕来实现。一块积木有多大,是由你决定的。
通过与MV(X)类的比较,可以发现在责任的分布上是有些不同的:
-
Model(数据交互)逻辑移到了Interactor,而Entity只是哑的数据结构。
-
Controller/ViewModel中,只有UI表达的职责交给了Presenter,而不包括改变数据的能力。
-
VIPER是第一个明确谈到导航职责的模式,由Router来解决。
对于iOS应用来说,实现合适的路径导航是一个具有挑战性的任务。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: "John", lastName: "Smith") // 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(("didTapButton:")), for: .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是在职责分布上是做的最好的。
- 可测试性:更好的分布带来了更好的可测试性。
- 易用性:最后,和你猜的一样,上述两者需要付出的代价是可维护性。你不得不为类写大量的接口,每个只负责很小的部分。
总结
我们介绍了几种设计模式,希望能对你有所帮助。可能你也意识到了,没有银弹,所以需要你在遇到实际问题时,权衡利弊,选择架构。
因此,可以在一些App中采用混合架构。举个例子,用MVC起步,然后发现采用MVC,有一个屏幕难以有效管理,可以只针对这个屏幕切换到MVVM。没有必要重构其他MVC工作的好好的屏幕,这两个架构是很容易兼容的。
Everything should be made as simple as possible, but no simpler. ⏤ Albert Einstein