iOS架构设计

作为iOS开发者应该听到过MVC,可能在考虑要不要转到MVVM,或者听说了VIPER这种高大上的,在想采用这种复杂的架构值不值?

本文试图回答以上问题,帮助大家对iOS架构有一个初步的了解。我们将通过一些简单的例子介绍架构的演进,他们的异同。

为什么要考虑架构选择的问题

因为开发时如果不采用架构,随着App复杂度的提高,势必出现一些巨大的类,在其中定位及修复bug都会变得越来越困难。代码组织很可能会是这样的:

  • 那些巨大的类是UIViewController的子类
  • 在UIViewController中操作数据
  • UIView基本不干啥
  • Model只是些数据结构,没有动作
  • 单元测试并没有覆盖到什么

让人更加郁闷的是,你明明是按照Apple的推荐来组织代码的,用的就是Apple的MVC架构。不奇怪,Apple的MVC是有问题的。

好的架构需要什么特性

  1. 不同模块角色明晰,代码均衡分布于这些模块上
  2. 由第1点带来的可测试性
  3. 易用性,维护成本低

随着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 MVC

Controller是沟通View和Model的中间人,View和Model之间相互是不知道的。其中重用性最低的是Controller,这并不是个问题,毕竟,总要有个地方放置业务逻辑。

这个结构看上去很容易理解。但现实的情况是View Controller会变的非常臃肿,给View Controller减肥成为开发者的一个重要课题。这是怎么发生的呢?

现实

Realistic Cocoa MVC

Cocoa 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是一个糟糕的设计模式。让我们用前文提到的特性列表来评估一下:

  1. 分布:View和Model确实是分开的,但是view和controller是紧密耦合的。
  2. 可测试性:由于糟糕的分布,你可能只会测试Model。
  3. 易用:在所有模式中代码量是最少的。另外,对所有人来说都很熟悉,所以即使是生手也能维护。

如果你不打算花大量时间在架构上,Cocoa MVC将是你的选择,或者你觉得用一个较高维护成本的架构开发一个小项目是杀鸡用牛刀。

对于开发速度来说,Cocoa MVC是最好的设计模式。

MVVM

MVVM是MV(X)系列中最新的一种,希望之前MV(X)中存在的一些问题,他都考虑进去了。

理论上Model-View-ViewModel看上去很不错。ViewModel我们已经很熟悉了,还包括中间人,由View Model表示。

MVVM
  • MVVM把view controller视作View。在这里,View Controller的子类实际上属于View,而不是中间人。
  • View和Model之间没有紧密耦合。
  • 中间人在这里表达为View Model

另外,在viewview 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方便。

假设不想自己实现,我们有两个选项:

事实上,现在当谈到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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容