过去一年半的时间我一直在做一个项目,它由一个简单的手机上的新闻阅读类应用充分发展成适用于手机和平板的虚拟报纸应用。一开始跟从苹果公司的建议,坚持使用MVC设计模式似乎是一个好主意。但是在这个应用持续发展的情况下,它里面的一些逻辑开始变得复杂,修改代码时总是伴随着一种忧虑的感觉,修改一部分代码的问题时害怕引起其他部分代码产生bug。把这仅仅归咎于MVC是不公平的,很显然一些问题是由坏的编程习惯、缺乏经验和项目最后期限引起的,但是MVC也有不好的地方。只想着模型、视图和控制器,你可能会错过一些有用的机会来把职责进一步划分,依赖关系图简化。
这篇文章中的“view”指的是一个用来展示一组数据的屏幕或者它的一部分,例如一个由imageView和label组成的collectionViewCell,或者一个由标题label、日期label和文本内容textView组成的故事板屏幕。换句话说,“view”指的不是UIView。
展示一个例子,假如我们需要一个视图来展示一片新闻。包含文章主题,标题和发布日期。我们先不管布局。
MVC情况下,定义控制器和模型(article)。在控制器里创建视图和模型的属性,在属性didSet方法里给视图设置值(注意:在构造方法里设置属性时,属性观察不会被调用)。
设想另一种情况,我们需要支持另一种结构的数据模型一个字典(这只是一个假设,用字典做模型是坏的选择)。
怎么进行呢?可以直接定义另一个属性,articleAsDictionary。
千万不要这么做。因为任何和模型的交互都需要判断好多if else来确定用哪个数据模型,这样会导致杂乱的和过于复杂的代码,难以阅读和发展,容易出bug。
你可能会想着把字典articleAsDictionary转换成模型Article,但是如果Article是core data中一个受管理的对象呢?没有一个受管理对象环境我们不能进行初始化。定义一个所有文章模型都遵守的协议也不太适用于我们的字典,也不支持我们随后介绍的一个特色。
开始解决之前,考虑另一件事情。我们的控制器里目前需要做的一些事情:把NSDate转换成string来显示,需要通过网络下载图片处理图片让imageView来显示,监听屏幕方向改变来调整视图,处理按钮点击和视图滑动,处理手势,关心状态保持,控制状态栏导航栏,管理内存。把模型处理逻辑的负担添加给控制器是应该避免的。这是在MVVM模式里第二个我们将会处理的问题。
在架构设计中你的目标应该是简单的部件(类)。要达到目的最终一般会限制类的角色和输入。这就是我们接下来将要对控制器类做的。我们将会把所有模型处理的逻辑提取出来,并且明确的定义输入需要的类型。最优雅的实现方式是定义一个协议来说明view(controller)真正地展示什么。那就是我们的viewModel。
protocol ArticleViewViewModel {
var title: String { get }
var body: String { get }
var date: String { get }
}
如你所见,协议中所有类型都是String,能够直接展示。并且这些属性只有获取方法,没有设置方法。
MVVM可以看做是Model-View-ViewModel,但是我们这里所构建的看做Model-View-ViewController-ViewMode更合适。由于view和controller如此紧密的被viewController联系在一起,我们把viewController也当做“view”。
这样一来,controller里面只需要把viewmodel的属性赋值给界面元素。我们让view controller独立于model,我们扔掉了所有丑陋的事情,比如说日期文字转换,模型适配。这样很棒,但是我们仍然需要真正地数据,这将会在一个实实在在的viewmodel里实现。
我们创建一个新类ArticleViewViewModelFromArticle来遵守ArticleViewViewModel协议,它需要一个article对象作为输入。
class ArticleViewViewModelFromArticle: ArticleViewViewModel {
let article: Article
let title: String
let body: String
let date: String
init(_ article: Article) {
self.article = article
self.title = article.title
self.body = article.body
let dateFormatter = NSDateFormatter()
dateFormatter.dateStyle = NSDateFormatterStyle.ShortStyle
self.date = dateFormatter.stringFromDate(article.date)
}
}
let article: Article = /* some article */
let viewModel = ArticleViewViewModelFromArticle(article)
let viewController = ArticleViewController()
viewController.viewModel = viewModel
控制器不需要知道model,它所接受的是遵守viewmodel协议的一些类型,所有的模型处理过程在viewmodel里面,viewController可以接受任何种类的viewmodel对象,只要他们遵守viewmodel协议。
我们实现的这个解决方案只适用于特定的场景。为什么?因为我们的viewmodel类里面的属性都设置为了常量,而不是变量,是不可改变的。如果我们把这些属性都设置成变量,也无济于事。让我们通过给article view添加一个缩略图imageview来澄清这件事。这样就需要一个image作为数据,但是我们只有一个url,怎么做?我们不关心。我们需要image类型的对象,因此我们的viewmodel就添加一个image属性。我们扩展一下viewmodel协议,让它能够提供image。同时扩展viewController。
protocol ArticleViewViewModel {
var title: String { get }
var body: String { get }
var date: String { get }
var thumbnail: UIImage? { get }
}
class ArticleViewController: UIViewController {
var bodyTextView: UITextView
var titleLabel: UILabel
var dateLabel: UILabel
var thumbnailImageView: UIImageView
var viewModel: ArticleViewViewModel {
didSet {
titleLabel.text = viewModel.title
bodyTextView.text = viewModel.body
dateLabel.text = viewModel.date
thumbnailImageView.image = viewModel.thumbnail
}
}
}
很简单。注意我们把image声明为了一个可选类型。待一会儿就能知道原因。(如果你希望有的文章没有缩略图,你也会这么做的,但这并不是我们这么做的原因)
谁提供这个图片?是一个实体viewmodel,我们扩展这个viewmodel,让它从模型article提供的url下载图片。
class ArticleViewViewModelFromArticle {
let article: Article
let title: String
let body: String
let date: String
var thumbnail: UIImage?
init(_ article: Article) {
self.article = article
self.title = article.title
self.body = article.body
let dateFormatter = NSDateFormatter()
dateFormatter.dateStyle = NSDateFormatterStyle.ShortStyle
self.date = dateFormatter.stringFromDate(article.date)
let downloadTask = NSURLSession.sharedSession().downloadTaskWithURL(article.thumbnail) {
[weak self] location, response, error in
if let data = NSData(contentsOfURL: location) {
if let image = UIImage(data: data) {
self?.thumbnail = image
}
}
}
downloadTask.resume()
}
}
棒极了!这管用吗?NO!因为异步下载图片需要一定的时间,当图片下载完成并给viewmodel的属性赋值的时候,我们的viewController早已经把viewmodel的值赋给自身的视图了。我们需要一种方式来传递这个改变。
我们可以发通知给viewcontroller,但是那样的话没有可扩展性,使得代码难以阅读和跟进,并且需要额外注意注册和注销监听的时机。另一种方式是为viewmodel定义一个代理协议,让viewcontroller去遵守它,这样更安全些,但是它引入了不必要的依赖,而且比发送通知更加缺乏可扩展性(想象一下给viewmodel里的每个对象定义一个通知方法,并在viewcontroller里面实现他们那种痛苦)。当然,这些方式我们都不会用。我们需要的是一种在属性层面的绑定机制。每当属性值改变的时候,我们都需要收到通知。
这样的机制叫做绑定。有几种方法可以实现。你可以用KVO机制来实现,但是它并不是swift原生的,会产生许多样板代码,并且难于debug。viewmodel类必须继承NSObject,还需要非常注意注册监听和注销监听。
另一个解决方案是ReactiveCocoa,它是一个启发自函数响应式编程的范例,绑定是它的核心内容。但是它是以OC的编程思想创建的(虽然swift也能使用)。除非你也使用它做其他的一些事情,否则引入它就显得太多的。
为了让viewmodel不可变,我们将要尝试一些更加swift的东西。下一篇文章,利用泛型和闭包的力量,我们将实现一个简单的绑定机制。