MVVM & ReactiveCocoa入门教程-第二部分

本文由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个参数分别为:

  1. 将要展示ViewModel数组内容的tableview
  2. 传播数组变化的数据源信号
  3. 一个可选的命令当一个row被选中时去执行
  4. 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方法。准备好,接下来是见证奇迹的时刻!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)initialize {
RACSignal *fetchMetadata =
[RACObserve(self, isVisible)
filter:^BOOL(NSNumber *visible) {
return [visible boolValue];
}];
@weakify(self)
[fetchMetadata subscribeNext:^(id x) {
@strongify(self)
[[[self.services getFlickrSearchService] flickrImageMetadata:self.photo.identifier]
subscribeNext:^(RWTFlickrPhotoMetadata *x) {
self.favorites = @(x.favorites);
self.comments = @(x.comments);
}];
}];
}

此方法的第一部分创建了一个叫做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:

  1. 原作者在博客中还加入了一些UI效果,但和MVVM无关,故没有翻译。
  2. 最终版的工程中还有一些功能但是博客并没有细致讲解,可自行查看。