本文由Colin Eberhardt于14年发表于raywenderlich,原文可查看MVVM Tutorial with ReactiveCocoa: Part 1/2,之前已经有过一篇还不错的翻译在这里,但是貌似只翻译了第一部分,于是我想到要翻译第二部分,顺便也重新翻译下第一部分当作复习。
你可能之前已经在Twitter上见过这个笑话:
“iOS Architecture, where MVC stands for Massive View Controller”
在iOS开发圈内这无疑是一个轻松的笑话,但是我确定你在实际开发中已经遇到这个问题:view controller臃肿不堪且难以管理。
这篇MVVM的教程提供了一种新的构建应用的方式,即Model-View-ViewModel,或者称之为MVVM。这种方式,再使用上ReactiveCocoa,提供了一种极好的替代MVC的选择性,可保证view controller轻量化。
跟着这篇MVVM教程,你将要构建一个简单的Flickr搜索程序,效果如下:
在开始写代码之前,先来了解一点原理。
ReactiveCocoa简短介绍
这篇教程主要是介绍MVVM的,假设你已经对ReactiveCocoa有了一点了解。
如果没有的话可首先查看这两篇无往而不胜的入门介绍。
ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2
ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
ReactiveCocoa博大精深,我只是做一个简短概括。
在ReactiveCocoa中最核心的内容是信号,即RACSignal类,信号会发送一系列包括三种类型:next,completed,error的事件流。
运用此,可以替代delegate,target-action,kvo,notification等方式。
运用信号API创建的代码可读性更好一些,但是真正强大之处在于你可以对这些信号进行很多高级别的操作,这些操作使得你能够以一种高效简洁的方式完成复杂的过虑,转换,信号结合等。
在MVVM的设计模式中,ReactiveCocoa起着特殊的作用,它提供了能够将ViewModel绑定到View上的胶水。
MVVM模式介绍
Model-View-ViewModel是一种UI设计模式,它是MV*大家庭中的一员,其他还包括Model View Controll(MVC),Model View Presenter(MVP)等等。
这些UI设计模式都致力于将UI逻辑和业务逻辑相分离,从而使程序更加容易开发和测试。
为了更好的理解MVVM这种设计模式,我们看看它是怎么来的。
MVC是最早的UI设计模式,可追溯到Smalltalk语言中。下面这幅图说明了MVC模式的主要组成部分:
这个模式将UI分成Model(表示程序状态)、View(由UI控件组成)、Controller(处理用户交互与更新model)。MVC模式的最大问题是它会令人相当困惑。它的概念看起来很好,但当我们实现MVC时,就会产生上图这种在Model,View,Controller之间的环状关系。这种相互关系将会导致相当的混乱。
最近Martin Fowler介绍了MVC模式的一个变种,这种模式命名为MVVM,并被微软广泛采用并推广。
不久以前Martin Fowler介绍了MVC模式的一个变种,这种模式命名为MVVM,并被微软广泛采用并推广。
这个模式的核心是ViewModel,它是一种特殊的model类型,用于表示程序的UI状态。
它包含了描述每个UI控件状态的属性。例如,文本输入框的当前文本,或者一个特定按钮是否可被点击;它同样暴露了视图可以执行的行为,如按钮点击或手势。
我们可以将ViewModel看作是视图的模型(model-of-the-view)。MVVM模式中的三部分比MVC更加简洁,下面是一些严格的限制:
View引用ViewModel,但反过来不行。
ViewModel引用Model,但反过来不行。
如果我们破坏了这些规则,便错误的应用了MVVM。
这个模式有一些立竿见影的优势:
轻量的视图:所有的UI逻辑都转移到ViewModel中。
便于测试:我们可以在没有视图的情况下运行整个程序,这样大大地增加了它的可测试性。
现在你可能注意到一个问题。如果View引用了ViewModel,但是反过来ViewModel没有引用View,那ViewModel如何更新View呢?啊哈,这就得靠MVVM模式的私密武器了。
MVVM和数据绑定
MVVM模式依赖于数据绑定,它是一个框架级别的特性,用于自动连接对象属性和UI控件。
例如,在微软的WPF框架中,下面的标签将一个TextField的Text属性绑定到ViewModel的Username属性中。
|
|
WPF框架将这两个属性绑定到一起。
这种双向绑定机制能够保证ViewModel中的Username属性变化时能够传递到TextField中的Text属性,反过来当用户操作TextField时也能反应到ViewModel中。
然而不幸的是,iOS没有数据绑定框架,幸运的是我们可以通过ReactiveCocoa来实现这一功能。
我们从iOS开发的角度来看看MVVM模式,ViewController及其相关的UI(nib, stroyboard或纯代码的View)一起组成了View:
……而ReactiveCocoa将View和ViewModel进行了绑定。
如果你感觉还OK,那我们开始新的征程。
开始新工程
首先从FlickrSearchStarterProject.zip中下载启动项目。
下载下来后pod install一下,然后打开工程,run一下后效果如下图:
花点时间先熟悉一下工程的结构:
Model和ViewModel分组目前是空的,我们一会儿会往里面添加东西。View分组包含:
RWTFlickSearchViewController:程序的主屏幕,包含一个搜索的text field和一个GO button。
RWTRecentSearchItemTableViewCell:用于在主页中显示最近搜索结果的table cell
RWTSearchResultsViewController:搜索结果页,显示来自Flickr的图片的tableview
RWTSearchResultsTableViewCell:渲染来自Flickr的单个图片的table cell
现在来增加第一个ViewModel!
第一个ViewModel
在ViewModel分组中添加一个继承自NSObject的新类RWTFlickrSearchViewModel。
然后在该类的头文件中,添加几个属性:
|
|
searchText属性表示text field中显示的文本,title属性表示导航条上的标题。
打开RWTFlickrSearchViewModel.m文件添加以下代码:
|
|
这段代码简单地设置了ViewModel的初始状态。
下一步就是将ViewModel连接到View上。记住View保留了一个对ViewModel的引用。在这种情况下,添加一个给定ViewModel来构造View的初始化方法是理所当然的。
注意:在MVVM模式下,Controller也变成了View的一员,这和系统的UIKit的使用方法大相径庭。
打开RWTFlickrSearchViewController.h,并导入ViewModel头文件,然后添加初始化方法:
@interface RWTFlickrSearchViewController : UIViewController
- (instancetype)initWithViewModel:(RWTFlickrSearchViewModel *)viewModel;
@end
在RWTFlickrSearchViewController.m中,在类的扩展中添加以下私有属性:
@property (weak, nonatomic) RWTFlickrSearchViewModel *viewModel;
然后添加初始化方法:
- (instancetype)initWithViewModel:(RWTFlickrSearchViewModel *)viewModel {
self = [super init];
if (self) {
_viewModel = viewModel;
}
return self;
}
这就在View中存储了一个ViewModel的引用。
注意:这只是一个弱引用。
接下来在viewDidLoad的最后添加下面代码:
[self bindViewModel];
该方法的实现如下:
- (void)bindViewModel {
self.title = self.viewModel.title;
self.searchTextField.text = self.viewModel.searchText;
}
以上的代码在UI初始化的时候执行,将ViewModel的状态应用到View上。
最后一步是创建一个ViewModel的实例对象,并将其提供给View。
在RWTAppDelegate.m中,导入ViewModel头文件,然后添加一个私有属性:
@property (strong, nonatomic) RWTFlickrSearchViewModel *viewModel;
你会发现这个类中已经有了一个createInitialViewController方法,用以下代码来更新它:
- (UIViewController *)createInitialViewController {
self.viewModel = [RWTFlickrSearchViewModel new];
return [[RWTFlickrSearchViewController alloc] initWithViewModel:self.viewModel];
}
这个方法创建了一个ViewModel实例,然后构造并返回了View。这个view作为程序navigation controller的初始视图。
重新run一下会发现View现在已经有了一些状态:
这样我们就得到了第一个ViewModel。不过仍然有许多东西要学的。你可能已经发现了我们还没有使用ReactiveCocoa。到目前为止,用户在输入框上的输入操作不会影响到ViewModel。
检测有效的搜索状态
在这一部分,将使用ReactiveCocoa来绑定ViewModel和View,从而将text field和button连接到ViewModel。
在RWTFlickrSearchViewController.m中,更新bindViewModel方法。
- (void)bindViewModel {
self.title = self.viewModel.title;
RAC(self.viewModel, searchText) = self.searchTextField.rac_textSignal;
}
在ReactiveCocoa中,使用了category将rac_textSignal属性添加到UITextField类中。它是一个信号,在text field每次更新时会发送一个包含当前文本的next事件。
RAC宏是一个绑定操作,上面的代码会使用rac_textSignal发出的next信号来更新viewModel的searchText属性。
搜索按钮应该只有在用户输入有效时才可点击。为了方便起见,我们以输入字符大于3时输入有效为准。在RWTFlickrSearchViewModel.m中导入以下头文件。
简而言之,这样确保了searchText属性总是能反映当前的UI状态。如果你已经懵逼了,还是乖乖的先去看看文章开头提到的两篇ReactiveCocoa入门介绍吧。
搜索按钮应该只有在用户输入内容有效时才可点击。为了方便起见,我们以输入字符数大于3时有效为准。在RWTFlickrSearchViewModel.m中添加以下头文件。
#import <ReactiveCocoa/ReactiveCocoa.h>
然后更新初始化方法:
|
|
运行程序然后在输入框中输入一些字符,在控制台中我们可以看到输入的内容全部转换成了有效或者无效的状态:
|
|
上面的代码使用RACObserve宏从ViewModel的searchText属性创建了一个信号(这是ReactiveCocoa对KVO的封装)。map操作将text转化为一个true或false值的流。
最后,distinctUntilChanges确保信号只有在状态改变时才发出值。
到目前为止,我们可以看到ReactiveCocoa被用于将View绑定到ViewModel,确保了这两者是同步的。更进一步地,ReactiveCocoa在ViewModel的内部用于观察自己的状态及执行其它操作。
这就是MVVM模式的基本模式。ReactiveCocoa用于绑定View和ViewModel是至关重要的,但在程序的其它层也非常有用。
添加搜索命令
在这一部分你将使用validSearchSignal来创建一个受控于View的操作。
打开RWTFlickrSearchViewModel.h并添加以下头文件:
#import <ReactiveCocoa/ReactiveCocoa.h>
添加以下属性:
@property (strong, nonatomic) RACCommand *executeSearch;
RACCommand是ReactiveCocoa中用于表示UI操作的一个类。它由一个代表UI操作结果的信号和表明这个操作当前是否正在被执行的状态组成。
在RWTFlickrSearchViewModel.m的initialize方法的最后添加以下代码:
|
|
这创建了一个当validSearchSignal发送true时可用的command。另外,在下面添加executeSearchSignal方法以提供这个command所执行的操作。
|
|
在这个方法中,会执行一些业务逻辑操作作为command执行的结果,并通过信号异步返回结果。
到目前为止,上面仅仅是一个假装的实现:空信号会立即完成。delay操作会将其所接收到的next或complete事件延迟两秒执行。为了使代码更逼真这是一个狡猾的策略。
最后一步是将这个命令连接到View中。打开RWTFlickrSearchViewController.m并在bindViewModel方法的结尾添加以下代码:
self.searchButton.rac_command = self.viewModel.executeSearch;
rac_command属性是ReactiveCocoa为UIButton添加的。上面的代码确保点击按钮执行给定的命令,且按钮的可点击状态反映了命令的可用状态。
运行程序,输入一些字符并点击GO:
可以看到,仅当text field中超过3个字母时按钮才可点击,点击后会置灰2秒钟表示此时不可点击,当执行的信号完成时又恢复可点击。从控制台,可以发现空信号会立即完成,而延迟操作会在2秒后发送事件:
|
|
屌爆了有木有?
绑定、绑定更多绑定
RACCommand监听了搜索按钮状态的更新,但处理activity indicator的可见性应由你负责。RACCommand暴露了一个executing属性,它是一个会发送true或false来标明命令开始和结束执行的信号。可以用这个来反映当前命令的状态。
在RWTFlickrSearchViewController.m中的bindViewModel方法结尾处添加以下代码:
RAC([UIApplication sharedApplication], networkActivityIndicatorVisible) = self.viewModel.executeSearch.executing;
这将UIApplication的networkActivityIndicatorVisible属性绑定到命令的executing信号上。这确保了不管命令什么时候执行,状态栏中的网络activity indicator都会显示。
接下来添加以下代码:
RAC(self.loadingIndicator, hidden) = [self.viewModel.executeSearch.executing not];
当命令执行时,应该隐藏加载indicator。这可以通过not操作来反转信号。
最后,添加以下代码:
|
|
以上代码确保命令执行时隐藏键盘。executionSignals属性发送由命令每次执行时生成的信号。这个属性是信号的信号(见ReactiveCocoa Tutorial – The Definitive Introduction: Part ½)。当创建和发出一个新的命令执行信号时,隐藏键盘。
运行程序看看效果如何吧。
Model哪儿去了?
到目前为止,已经有了一个清晰的View(RWTFlickrSearchViewController)和ViewModel(RWTFlickrSearchViewModel),但是Model在哪呢?
答案很简单:并没有!
当前的程序会执行一个命令来响应用户点击搜索按钮的操作,但是实现不做任何值的处理。
ViewModel真正需要做的是使用当前的searchText来搜索Flickr,然后返回一个匹配的列表。
你可以把这个逻辑直接放到ViewModel中,但是相信我,你会后悔的。如果这是一个viewcontroller,我打赌你一定会直接这么做。
ViewModel暴露属性来表示UI状态,它同样暴露命令来表示UI操作(通常是方法)。它负责管理基于用户交互的UI状态的改变。
然而它不负责实际执行这些交互产生的的业务逻辑,那是Model的工作。
下一步,在程序中添加Model层。
在Model分组中,添加名为RWTFlickrSearch的协议并提供以下实现:
|
|
这个协议定义了Model层的初始接口,并将搜索Flickr的责任移出ViewModel。
接下来在Model分组中添加RWTFlickrSearchImpl类,其继承自NSObject,并实现了RWTFlickrSearch协议:
|
|
打开RWTFlickrSearchImpl.m文件,提供以下实现:
|
|
看着是不是似曾相识?没错,我们在上面的ViewModel中有相同的实现。
下一步是在ViewModel中使用Model层。在ViewModel分组中添加一个叫做RWTViewModelServices的协议并如下实现:
|
|
这个协议定义了唯一的一个方法,以允许ViewModel获取一个RWTFlickrSearch协议的实现对象的引用。
打开RWTFlickrSearchViewModel.h并导入头文件
#import "RWTViewModelServices.h"
更新初始化方法以将它作为一个参数:
- (instancetype) initWithServices:(id<RWTViewModelServices>)services;
在RWTFlickrSearchViewModel.m中,添加一个class extension和一个私有属性来获得一个view model services的引用:
|
|
在该文件下面,添加初始化方法:
|
|
这样就简单存储了services的引用。
最后,更新executeSearchSignal方法:
|
|
上面的方法将实际执行搜索的任务转移到了model(层)中。
最后一步是连接Model和ViewModel。
在工程的根分组中,添加一个NSObject的子类RWTViewModelServicesImpl。打开RWTViewModelServicesImpl.h文件并实现RWTViewModelServices协议:
|
|
打开RWTViewModelServicesImpl.m,并添加实现:
|
|
这个类简单创建了一个RWTFlickrSearchImpl实例,Model层为搜索Flickr服务,当有请求的时候将其提供给ViewModel。
最后,打开RWTAppDelegate.m文件,添加以下头文件
#import "RWTViewModelServicesImpl.h"
并添加一个新的私有属性
@property (strong, nonatomic) RWTViewModelServicesImpl *viewModelServices;
再更新createInitialViewController方法:
|
|
运行程序,验证程序有没有按之前的方式来工作。当然,这不是最令人兴奋的变化,不过先花一点时间看看新代码的架构吧。
Model层暴露了一个ViewModel层使用的‘服务’。一个协议定义了这个服务的接口,提供了松散的组合。
你可以使用这种方式来为单元测试提供一个类似的服务实现。程序现在有了正确的MVVM结构,让我们小结一下:
- Model层暴露服务并负责提供程序的业务逻辑实现。具体来说,它提供了搜索Flickr的服务。
- ViewModel层表示程序中视图的状态,同时响应用户交互及来自Model层的事件,两者都会反映到view-state的变化。
- View层非常薄,只提供ViewModel状态的视觉展示以及传递用户交互。
注意:在此应用中model层对外暴露服务是应用的ReactiveCocoa中的信号(signals),可以这个库不仅仅能够进行绑定。
搜索Flickr
在这一部分你将提供一个真正的Flickr的搜索实现,事情开始变得令人兴奋了哈。
第一步就是创建能够表示搜索结果的模型对象。在Model分组中,添加RWTFlickrPhoto类,并为其添加三个属性。
|
|
这个模型对象表示由Flickr搜索API返回一个图片。
打开RWTFlickrPhoto.m,并添加以下描述方法的实现:
|
|
这能允许你在进行UI改变之前通过打印结果来检测搜索实现是否正确。
下一步,增加一个新的模型对象类RWTFlickrSearchResults,并添加以下属性:
|
|
这个类表示由Flickr搜索返回的照片集合。
打开RWTFlickrSearchResults.m,并添加以下描述方法的实现(当然还是出于打印的目的):
|
|
是时候实现搜索Flickr了。打开RWTFlickrSearchImpl.m并导入以下头文件:
|
|
ObjectiveFlickr-这是一个OC API对Flickr API的封装,处理了授权和解析响应。这比直接使用原生Flickr API简单多了。
LinqToObjectiveC-这个库提供了一系列针对数组和字典进行查询,过滤和转换的函数式接口。
还是在RWTFlickrSearchImpl.m文件中,添加一个类扩展:
|
|
这个类实现了OFFlickrAPIRequestDelegate协议,并添加了两个私有属性。你将很快看到如何使用它们。
继续添加代码:
|
|
这段代码创建了一个Flickr的上下文,用于存储ObjectiveFlickr请求的数据。
注意:为了使用ObjectiveFlickr你需要在Flickr App Garden创建一个 Flickr App Key。
ObjectiveFlickr API非常典型。创建API请求后成功或失败的回调都能通过代理方法得到。
当前API由Model层服务类对外暴露,具体实现是实现RWTFlickrSearch协议中唯一的方法,从而基于文本搜索字符来查找图片。不过你很快就会添加更多的方法。
在RWTFlickrSearchImpl.m中添加以下方法:
|
|
这个方法根据传入的方法名及参数触发一个API请求,然后使用block参数来转换响应对象。现在分别来看看每一步:
createSignal方法创建了一个新的信号,block中的subscriber暴露的方法允许你发送next,error和completed事件给信号的订阅者们。
一个ObjectiveFlickr请求被创建,然后存储了这个请求。
rac_signalForSelector:fromProtocol:方法基于表示Flickr API请求完成的代理方法创建了一个信号。
信号被订阅,结果进行了转换之后作为创建的信号的结果被发送出去。(稍后再细说)
ObjectiveFlickr API请求被触发。
当这个信号被废弃时,这个block能保证Flickr请求的引用被移除,避免内存泄漏。
现在我们来仔细看一下第4步:
|
|
rac_signalForSelector:fromProtocol: 方法创建了successSignal,同样也在代理方法的调用中创建了信号。
每次代理方法被调用时,一个包含方法参数的RACTuplenext事件会发送出去。
上面的管道按照如下步骤执行:
- 一个map操作从flickrAPIRequest:didCompleteWithResponse: 代理方法中取出第二个参数:响应的字典。
- 作为参数传进来的block对结果进行转换,你将很快见到怎么样将字典转换成model对象。
- 最后,被转换后的响应被作为一个nex事件发送出去,然后这个信号完成,生命周期结束了。
实现Flickr搜索的最后一步如下:
|
|
上面的方法使用了刚才添加的signalFromAPIMethod:arguments:transform:方法。flickr.photos.search API中搜索照片的方法会提供标准的字典内容。
传递给transform参数的block简单地将NSDictionary响应转化为一个等价的模型对象,让它在ViewModel中更容易使用。
上面代码使用了LinqToObjectiveC给NSArray添加的linq_select方法,从而能够应用函数式的API转换数组。
注意:对于更加复杂的JSON-to-object的转化,我强烈推荐你去看看Mantle。
最后一步是打开RWTFlickrSearchViewModel.m方法,然后更新搜索信号来记录日志:
|
|
编译,运行并输入一些字符后可在控制台看到以下日志:
|
|
注意:如果你没有得到结果,再检查一下你的 Flickr API key和shared secret。
这样我们MVVM指南的第一部分就差不多结束了,但在结束之前,让我们先看看内存问题吧。
内存管理
假设此时此刻你已经从上面提到过的ReactiveCocoa教程中了解了怎样使用@weakify和@strongify两个宏定义来避免循环引用。
你是否好奇为什么signalFromAPIMethod:arguments:transform:方法实现中当引用self时没有使用这两个宏?
这是因为block是作为createSignal:方法的一个参数,它不会在self和block之间建立一个强引用关系。迷茫了吧?不相信的话去测试一下这段代码有没有内存泄露吧。
何去何从?
到目前为止本教程中demo工程的完整代码可以在这里下载。这包括了MVVM & ReactiveCocoa入门教程的第一部分。
在下一部分中,你将看到如何从ViewModel中初始化一个视图控制器并实现更多的Flickr API请求从而使这个应用内容更加丰富。