本文由Colin Eberhardt于14年发表于raywenderlich,原文可查看MVVM Tutorial with ReactiveCocoa: Part 2/2。
Model-View-ViewModel (MVVM) 是一种正在逐渐成为Model-View-Controller (MVC)的替代方案的UI设计模式。
在本教程的第一部分中,你已经见识到ReactiveCocoa是怎样扮演‘胶水’的角色来将ViewModel绑定到相应的View上。
下面是你要构建的应用最终运行后的样子,它是一个Flickr搜索app。
在本篇第二部分中,你将看到能怎样利用程序中的ViewModel来驱动不同controller中的navigation。
到目前为止,你所开发的应用允许你应用一个简单的字符串来搜索Flickr。如果你需要当前工程的一个备份,猛击这里。
一个Model层的服务利用ReactiveCocoa来提供搜索结果,而ViewModel仅仅是打印出来。
现在到了解决怎样跳转到搜索结果页问题的时间了。
声明ViewModel导航
当一个Flickr搜索成功返回期望的数据,应用导航要跳转到一个展示搜索结果的新的controller页面。
当前应用只有一个ViewModel,是RWTFlickrSearchViewModel类。为了实现期望的效果,需要增加一个新的ViewModel来为搜索结果View提供支持。
在ViewModel分组中增加一个继承自NSObject的子类命名为RWTSearchResultsViewModel,然后修改如下:
@import Foundation;
#import "RWTViewModelServices.h"
#import "RWTFlickrSearchResults.h"
@interface RWTSearchResultsViewModel : NSObject
- (instancetype)initWithSearchResults:(RWTFlickrSearchResults *)results services:(id<RWTViewModelServices>)services;
@property (strong, nonatomic) NSString *title;
@property (strong, nonatomic) NSArray *searchResults;
@end
上面添加了几个描述View的属性和一个以RWTFlickrSearchResults模型对象(由Model层的服务返回)构建的初始化方法。
打开RWTSearchResultsViewModel.m文件,实现初始化方法:
- (instancetype)initWithSearchResults:(RWTFlickrSearchResults *)results services:(id<RWTViewModelServices>)services {
if (self = [super init]) {
_title = results.searchString;
_searchResults = results.photos;
}
return self;
}
这样就完成了RWTSearchResultsViewModel类。
如果你回忆一下第一部分,ViewModel是在其相对应的View出现之前就已经构建好了然后‘驱动’整个工程的。下一步就是给View匹配上对应的ViewModel。
打开RWTSearchResultsViewController.h文件,导入ViewModel头文件,增加初始化方法如下:
#import "RWTSearchResultsViewModel.h"
@interface RWTSearchResultsViewController : UIViewController
- (instancetype)initWithViewModel:(RWTSearchResultsViewModel *)viewModel;
@end
打开RWTSearchResultsViewController.m文件,在类拓展中增加私有属性:
@property (strong, nonatomic) RWTSearchResultsViewModel *viewModel;
然后实现初始化方法:
- (instancetype)initWithViewModel:(RWTSearchResultsViewModel *)viewModel {
if (self = [super init]) {
_viewModel = viewModel;
}
return self;
}
在这一步,你要关注于navigation是怎样工作的。一会儿再来给这个controller绑定ViewModel。
整个工程现在有了两个ViewModel,但是你遇到了一个难题:怎样从一个ViewModel切换到另一个,然后同时用导航跳转到它们各自对应的controller呢?
ViewModel不能对View有直接引用,所以到底应该怎么办呢?
其实答案就存在于RWTViewModelServices协议中。它当前被用于获得一个Model层的引用,现在还用这个协议来允许ViewModel初始化导航。
打开RWTViewModelServices.h文件增加一个新的协议方法:
- (void)pushViewModel:(id)viewModel;
概念上讲,是ViewModel层驱动整个应用;这一层中的逻辑决定View上展示什么,也决定了何时以何种方法使导航出现(实现页面跳转)。
这个方法允许ViewModel层初始化导航:‘pushing’到一个新的ViewModel和一个UINavigationController允许你‘pushing’到一个新的Controller其实异曲同工。
在完成协议实现时,还要在ViewModel层做些事情。
打开RWTFlickrSearchViewModel.m文件导入新增加的ViewModel头文件:
#import "RWTSearchResultsViewModel.h"
然后更新executeSearchSignal方法实现:
- (RACSignal *)executeSearchSignal {
return [[[self.services getFlickrSearchService]
flickrSearchSignal:self.searchText]
doNext:^(id result) {
RWTSearchResultsViewModel *resultsViewModel =
[[RWTSearchResultsViewModel alloc] initWithSearchResults:result services:self.services];
[self.services pushViewModel:resultsViewModel];
}];
}
上面方法中为当搜索命令执行时创建的信号增加了一个doNext的操作。doNext block中创建了一个新的ViewModel来展示搜索结果,然后通过ViewModel的services实现push。
现在到了更新代码来实现协议的时候了,这样当一个ViewModel被push也会导航到必要的controller上。为了实现次,需要有一个navigation controller的引用。
打开RWTViewModelServicesImpl.h文件增加初始化方法:
- (instancetype)initWithNavigationController:(UINavigationController *)navigationController;
打开RWTViewModelServicesImpl.m文件导入头文件:
@property (weak, nonatomic) UINavigationController *navigationController;
然后实现初始化方法:
- (instancetype)initWithNavigationController:(UINavigationController *)navigationController {
if (self = [super init]) {
_searchService = [RWTFlickrSearchImpl new];
_navigationController = navigationController;
}
return self;
}
这样简单更新初始化方法就存储了一个navigation controller的引用。
最后,增加新方法:
- (void)pushViewModel:(id)viewModel {
id viewController;
if ([viewModel isKindOfClass:RWTSearchResultsViewModel.class]) {
viewController = [[RWTSearchResultsViewController alloc] initWithViewModel:viewModel];
} else {
NSLog(@"an unknown ViewModel was pushed!");
}
[self.navigationController pushViewController:viewController animated:YES];
}
上面的方法运用这种方式来让ViewModel决定需要展示哪个View。
在上面的代码中,只有一对具体的ViewModel-View,不过我确信你能看出来怎么样拓展更多。navigationController会push到结果View页。
最后一步:打开RWTAppDelegate.m文件,在createInitialViewController方法中找到RWTViewModelServicesImpl实例被创建的地方,更新代码以通过初始化方法将navigation controller传进去。
self.viewModelServices = [[RWTViewModelServicesImpl alloc] initWithNavigationController:self.navigationController];
运行一下,输入一些内容,点击‘Go’按钮观察应用会过渡到新的ViewModel/View。
新页面是空的!不用沮丧,你很快就会添加内容的。
你现在有了一个拥有多个ViewModel的应用,而且通过ViewModel层来控制所有controller的跳转。
完成的差不多了,现在开始UI绑定。
渲染结果列表
展示搜索结果的View,也就是RWTSearchResultsViewController中,有一个在xib中初始化的UITableView。下一步就是用这个表来展示ViewModel的内容。
打开RWTSearchResultsViewController.m文件,找到类的拓展,更新代码以实现UITableViewDataSource协议。
@interface RWTSearchResultsViewController () <UITableViewDataSource>
然后重写viewDidLoad方法:
- (void)viewDidLoad {
[super viewDidLoad];
[self.searchResultsTable registerClass:UITableViewCell.class
forCellReuseIdentifier:@"cell"];
self.searchResultsTable.dataSource = self;
[self bindViewModel];
}
在下面添加bindViewModel方法:
- (void)bindViewModel {
self.title = self.viewModel.title;
}
此时,这个方法并没有做很多事情。这个ViewModel有两个属性:一个title显示在导航条上,另一个searchResults数组将会渲染在table上。
那怎么样将这个数组绑定到table view上呢?不幸的是,没有现成的答案。
ReactiveCocoa能绑定简单的属性到UIKit子控件上,但是不能处理复杂的情况,比如说将数据放入table view中。我知道,这很令人扫兴。
但是没必要恐慌,因为有另外一种方法。是时候卷起袖子大干一场了。
还是在这个文件中,增加这两个必须的协议方法:
- (NSInteger)tableView:(UITableView *)tableView
numberOfRowsInSection:(NSInteger)section {
return self.viewModel.searchResults.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell"];
cell.textLabel.text = [self.viewModel.searchResults[indexPath.row] title];
return cell;
}
第一个方法说明有多少条搜索结果,第二个方法用cell的title属性来展示来自ViewModel中的数据。
运行一下,可以看到现在列表中有数据了。
更好的Table View绑定
table view缺少绑定的话会迅速导致原本已经瘦身的controller再次臃肿。
这个问题困扰了我,所以我决定要解决它。
概念上来说,ViewModel中的searchResults数组中的每一项都应该也是一个ViewModel,每一个cell成为相应ViewModel实例所对应的View。
在最近的一篇博客中我创造了一个通用的绑定帮助类,命名为CETableViewBindingHelper,它会帮助你实现datasource协议中必要的方法,从而允许你用每个子ViewModel定义相应的View。你能在当前工程的Util分组中找到这个帮助类。
CETableViewBindingHelper类的初始化类方法如下:
+ (instancetype) bindingHelperForTableView:(UITableView *)tableView
sourceSignal:(RACSignal *)source
selectionCommand:(RACCommand *)selection
templateCell:(UINib *)templateCellNib;
为了将一个数组和一个view进行绑定,你只需要简单创建一个这个帮助类的实例。4个参数分别为:
- 将要展示ViewModel数组内容的tableview
- 传播数组变化的数据源信号
- 一个可选的命令当一个row被选中时去执行
- cell的nib
给定nib文件的cell必须实现了CEReactiveView协议。
工程已经包含了一个你可以用于渲染搜索结果的cell。
打开RWTSearchResultsTableViewCell.h文件,导入必要的协议:
#import "CEReactiveView.h"
然后去应用它。
@interface RWTSearchResultsTableViewCell : UITableViewCell <CEReactiveView>
下一步就是实现这个协议。打开RWTSearchResultsTableViewCell.m文件增加如下代码:
#import <SDWebImage/UIImageView+WebCache.h>
#import "RWTFlickrPhoto.h"
然后增加以下方法:
- (void)bindViewModel:(id)viewModel {
RWTFlickrPhoto *photo = viewModel;
self.titleLabel.text = photo.title;
self.imageThumbnailView.contentMode = UIViewContentModeScaleToFill;
[self.imageThumbnailView setImageWithURL:photo.url];
}
当前RWTSearchResultsViewModel中的searchResults属性是包含了RWTFlickrPhoto实例的数组,而没有用ViewModel去包裹这些Model对象,view可以直接使用。
注意:有时候有一些view只是用来展示而不需要进行用户交互,此时直接将Model对象暴露给View也是没有问题的。保持简单,无需增加不必要的复杂度。
你刚刚添加的bindViewModel方法同时使用了SDWebImage库。它会在后台线程进行图片下载和解码,从而提高scroll的性能。
最后一步是用刚才说的绑定帮助类来渲染table。
打开RWTSearchResultsViewController.m文件导入帮助类:
#import "CETableViewBindingHelper.h"
在当前文件中,移除UITableDataSource协议的实现与两个代理方法。
接着,在类的拓展里增加一个私有属性:
@property (strong, nonatomic) CETableViewBindingHelper *bindingHelper;
然后在viewDidLoad方法中删除不再需要的代码,改成下面的样子:
- (void)viewDidLoad {
[super viewDidLoad];
[self bindViewModel];
}
最后,在bindViewModel方法的最后添加如下代码:
UINib *nib = [UINib nibWithNibName:@"RWTSearchResultsTableViewCell" bundle:nil];
self.bindingHelper =
[CETableViewBindingHelper bindingHelperForTableView:self.searchResultsTable
sourceSignal:RACObserve(self.viewModel, searchResults)
selectionCommand:nil
templateCell:nib];
这样从nib文件中创建一个UINib的实例来构建这个绑定帮助对象,而sourceSignal是由观察ViewModel中的searchResults属性的变化而创建的。
再运行一下,可以欣赏一会儿新UI了:
这是一个相当优雅的将table view和数组进行绑定的方法。
查询评论和喜爱数量
cell应该在右下方展示每张照片的评论和喜爱数量。然而,现在仅仅是展示了‘123’的假数据。
你应该在将其替换成真正的值之前先在Model层添加这个功能。首先增加一个表示Flickr API查询结果的Model对象。
在Model分组中创建一个继承自NSObject的子类RWTFlickrPhotoMetadata,打开.h文件增加如下属性:
@property (nonatomic) NSUInteger favorites;
@property (nonatomic) NSUInteger comments;
然后打开.m文件增加description方法的实现:
- (NSString *)description {
return [NSString stringWithFormat:@"metadata: comments=%lU, faves=%lU",
self.comments, self.favorites];
}
当然这个方法还是为了测试时输出日志的方便。
为可见Cell查询元数据
你可以拓展当前的代码以实现为所有搜索的结果查询元数据。
然后,如果一次结果中有100张照片,这样就会产生200的API请求,或者说每张照片有两个请求。很显然,这是相当不合理的。
事实上,你只需要为当前表中正在可见的cell查询元数据。所以怎么实现呢?你需要一个清楚自身是否可见的ViewModel。
当前RWTSearchResultsViewModel暴露了一个包含RWTFlickrPhoto实例集合的数组用于为View提供数据,这些Model层的对象直接暴露给了View。为了增加可见的概念,你需要用ViewModel来包裹这些model对象。
在ViewModel分组中添加一个NSObject的子类命名为RWTSearchResultsItemViewModel。打开头文件更新如下:
@import Foundation;
#import "RWTFlickrPhoto.h"
#import "RWTViewModelServices.h"
@interface RWTSearchResultsItemViewModel : NSObject
- (instancetype) initWithPhoto:(RWTFlickrPhoto *)photo services:(id<RWTViewModelServices>)services;
@property (nonatomic) BOOL isVisible;
@property (strong, nonatomic) NSString *title;
@property (strong, nonatomic) NSURL *url;
@property (strong, nonatomic) NSNumber *favorites;
@property (strong, nonatomic) NSNumber *comments;
@end
从初始化方法中可以看出,ViewModel包裹了一个RWTFlickrPhoto的模型对象。
ViewModel的属性包括如下作用:
- 暴露Model的基本属性(title,url)
- 当元数据被查询到时动态更新
- isVisible用于表明这个ViewModel当前是否可见。
打开RWTSearchResultsItemViewModel.m文件,导入头文件:
#import <ReactiveCocoa/ReactiveCocoa.h>
#import <ReactiveCocoa/RACEXTScope.h>
#import "RWTFlickrPhotoMetadata.h"
在下面增加一个类拓展并且添加几个私有属性:
@interface RWTSearchResultsItemViewModel ()
@property (weak, nonatomic) id<RWTViewModelServices> services;
@property (strong, nonatomic) RWTFlickrPhoto *photo;
@end
实现初始化方法:
- (instancetype)initWithPhoto:(RWTFlickrPhoto *)photo services:(id<RWTViewModelServices>)services {
self = [super init];
if (self) {
_title = photo.title;
_url = photo.url;
_services = services;
_photo = photo;
[self initialize];
}
return self;
}
title和url属性从Model对象中获取,然后通过私有属性存储了services和photo参数的引用。
接着增加initialize方法。准备好,接下来是见证奇迹的时刻!
|
|
此方法的第一部分创建了一个叫做fetchMetadata的信号用于观察isVisible属性并且过滤出来’true’的值。作为结果,当isVisible属性变为true时,这个信号会发送一个next的值。
第二部分订阅了这个信号以为了触发flickrImageMetadata方法进行请求。当里面的信号发送一个next事件,favorites和comments会更新结果。
总之呢,当isVisible变为true,Flickr API请求会被触发,comments和favorites属性稍后会被更新。
为了将这个ViewModel投入使用,打开RWTSearchResultsViewModel.m文件导入以下头文件:
#import <LinqToObjectiveC/NSArray+LinqExtensions.h>
#import "RWTSearchResultsItemViewModel.h"
在初始化方法中,去掉当前设置_searchResults的代码,以下面的代替:
_searchResults =
[results.photos linq_select:^id(RWTFlickrPhoto *photo) {
return [[RWTSearchResultsItemViewModel alloc]
initWithPhoto:photo services:services];
}];
这样就用ViewModel包裹了每一个Model对象。
最后一步就是通过View来设置isVisible属性然后使用这些新属性。
打开RWTSearchResultsTableViewCell.m文件,导入新的头文件:
#import "RWTSearchResultsItemViewModel.h"
然后改变bindViewModel方法中的第一行来使用新添加的ViewModel:
RWTSearchResultsItemViewModel *photo = viewModel;
还是这个方法,添加如下:
[RACObserve(photo, favorites) subscribeNext:^(NSNumber *x) {
self.favouritesLabel.text = [x stringValue];
self.favouritesIcon.hidden = (x == nil);
}];
[RACObserve(photo, comments) subscribeNext:^(NSNumber *x) {
self.commentsLabel.text = [x stringValue];
self.commentsIcon.hidden = (x == nil);
}];
photo.isVisible = YES;
这样就观察了新的comments和favorites属性,然后用于更新相应的label和图片。
最后,将ViewModel的isVisible属性设为YES。
最终版本的工程可到这里下载。
PS:
- 原作者在博客中还加入了一些UI效果,但和MVVM无关,故没有翻译。
- 最终版的工程中还有一些功能但是博客并没有细致讲解,可自行查看。