MVVM在ReactiveCocoa下的实现

原文:MVVM Tutorial with ReactiveCocoa: Part 1/2

你可能之前在推特看过这么一个笑话:

*“iOS Architecture, where MVC stands for Massive View Controller” *

这是对iOS开发者的一个小小调侃,但我敢说你们肯定都在实践中遇到过这样的问题:臃肿而又难于管理的视图控制器。

这篇教程将介绍一个构造应用的崭新模式:Model-View-ViewModel(MVVM)。这种得益于ReactiveCocoa的模式为我们提供了一个优异的MVC替代方案,让视图控制器更加有序和轻量。

在这篇MVVM的教程中,你将要构建一个简单的Flickr搜索应用,效果如下:


注意:这篇教程基于Objective-C,并非Swift。同时开发环境使用的是Xcode 5,而非Xcode 6,所以所有的截图都从Xcode 5中截取。为了得到最好的效果,请使用Xcode 5来完成本教程。(译注:因为是14年的文章,所以Xcode版本比较老旧,但具体界面操作还是大同小异)
但无路如何,如果你实在对这个示例项目的Swift版本感兴趣的话,你可以关注我最近的博客中的实现!(译注:因为Swift几个版本以来API和语法更新较多,ReactiveCocoa对应也有较大的更新,当时的代码估计已经不再适用)

在编写代码之前,我们先来学习一点理论知识。

ReactiveCocoa的简短回顾

这篇教程是主要介绍MVVM,并默认你已经掌握了ReactiveCocoa的相关知识点。如果你之前从来没有使用过ReactiveCocoa,我强烈建议你阅读一下早前相关的教程

如果你需要对ReactiveCocoa进行快速复习,下面我会简短地回顾一些关键点。

ReactiveCocoa最核心的就是信号,对应表现为RACSignal类。信号发送事件流,而事件流又分三种类型:nextcompletederror

使用这种简单的模型,ReactiveCocoa可以对代理模式, target-action模式,键值对观察(KVO)等进行替代。

由于信号创建的接口更加同质(homogenous),所以代码更加易读。但ReactiveCocoa更强大的地方体现在对这些信号的高级操作。那让你可以用相当简洁的方式实现复杂的过滤,转换和信号协调。

ReactiveCocoa在MVVM的实现中是一个特殊的存在。它是ViewModel和View的“粘合剂”。但这概念对现在的你来说可能有点超前了……

MVVM的介绍

众所周知,Model-View-ViewModel(MVVM)是一中UI设计模式。它是MV*模式众多成员中的一份子,其他还包括Model View Controller (MVC),Model View Presenter (MVP)等。

这些模式都关注于将UI逻辑从业务逻辑中分离出来,让应用更易于开发与测试。

注意:想了解更多常见的设计模式,我推荐Eli或者Ash Furrow的文章。

为了更好理解MVVM模式,我们可以回顾一下它的起源。

MVC是第一个UI设计模式,它的起源可以追溯到二十世纪七十年代的Smalltalk语言。下图展示了MVC模式的主要组成:

这个模式将UI视图分解为三个部分:表示应用状态的Model,UI控件组成的View,和根据情况处理交互和更新模型的Controller。

而MVC模式最大的问题就是它迷惑性。这概念虽然看起来很美,但当人们开始实现MVC时,Model,View和 Controller之间就会形成上图中相似的循环关系。换言之,这导致了一种可怕的混乱。

而最近Martin Fowler提出了一种称为Presentation Model的MVC变体,这种变体被微软以MVVM为名,采纳并普及开来。

这种模式的核心是ViewModel,一种用以代表应用UI界面的状态的特殊模型。

其包含了每个UI控件的状态属性。例如,文本输入框的实时内容,或者指定按钮的可用与否。它也暴露了视图的具体的可用操作,比如按钮的点击或手势。

可以将ViewModel理解为视图的模型(model-of-the-view)。

MVVM模式中三个组件间的关系比MVC中的更加简单,具体遵循这些严格的规定:

  1. View能引用ViewModel,但ViewModel不能引用View。
  2. ViewModel能引用Model,但Model不能引用ViewModel。

只要你违反了其中一条规定,那么你就没有正确的使用MVVM!

这种模式有两个立竿见影的好处:

  1. 轻量级的视图:所有的UI逻辑都迁移到ViewModel中,剩下的视图非常轻量。
  2. 方便测试:你可以脱离视图运行应用逻辑,显著地提升了可测试性。

注意:众所周知,视图测试相当困难,因为哪怕是再小的一个测试都会涉及大量不相关的代码。通常来说,控制器会对依赖于其他的应用状态的场景添加和设置视图。这意味着有效的小型测试将是一个脆弱而繁琐的命题。

此时你可能会提出一个问题。如果View只能单向引用ViewModel,ViewModel又如何更新View呢?

这正是MVVM模式的秘诀。

MVVM和数据绑定

MVVM模式依赖于数据绑定这样一个框架级的功能,用以自动连接对象属性和UI控件。

举个例子,在微软的WPF框架中,下面的标签绑定了文本框的Text属性和ViewModel的Username属性:

<TextField Text=”{DataBinding Path=Username, Mode=TwoWay}”/>

WPF框架让这两个属性关联在一起。

这种双向绑定一方面让Username的属性改动得以传递到文本框的Text属性中,而另一方面,又能把用户的输入反映给ViewModel。

在另一个流行的网页端MVVM框架Knockout中,你也可以看到相似的绑定操作:

<input data-bind=”value: username”/>

上面的代码就把一个HTML元素的属性与一个JavaScript对象绑定起来。

很不幸,iOS缺乏这样一个数据绑定的框架,但这正是ReactiveCocoa发挥“粘合剂”作用的地方,将ViewModel进行关联。

现在特别从iOS开发的角度研究一下MVVM模式。我们能发现视图控制器和它关联的UI(无论是nib,stroyboard还是通过代码创建的)共同组成了View部分,而ReactiveCocoa则将View与ViewModel绑定起来:

注意:想了解更多关于UI模式的历史分析,我强烈推荐Martin Fowler的Martin Fowler’s GUI Architectures

你是不是已经受够这些理论了?好吧,如果不是你可以往上再重读一遍。哦?你都已经掌握了?好吧,那接下来是时候创建你自己的ViewModel了。

初始的项目结构

首先下载下面的初始项目:

项目使用CocoaPods去管理依赖包(如果你是第一次接触CocoaPods,我们为你准备了一个新手教程!)。执行pod install命令去获取依赖,确认你能看到下面的输出:

译注:由于当时CocoaPods的版本较低,现今Podfile文件的格式有所改变,更新如下:

platform :ios, '7.0'
target 'RWTFlickrSearch' do
    pod 'ReactiveCocoa', '2.1.8'
    pod 'objectiveflickr', '2.0.4'
    pod 'LinqToObjectiveC', '2.0.0'
    pod 'SDWebImage', '3.6'
end
$ pod install
Analyzing dependencies
Downloading dependencies
Installing LinqToObjectiveC (2.0.0)
Installing ReactiveCocoa (2.1.8)
Installing SDWebImage (3.6)
Installing objectiveflickr (2.0.4)
Generating Pods project
Integrating client project

你会在接下来的使用中学到更多关于这些依赖包的的内容。

译注:这个版本(3.6)的SDWebImage在Xcode6及后续版本有报错,请在@implementation SDWebImageDownloaderOperation {...}后添加:

@synthesize executing = _executing ;  
@synthesize finished = _finished;  

这个教程的初始工程已经包含了用视图控制器和nib文件实现的应用视图。打开由CocoaPods 生成的RWTFlickrSearch.xcworkspace文件,编译运行初始项目后你就能看到这些视图中的一个:

花点时间熟悉一下项目的架构:


Model和ViewModel分组中现在为空;你很快将往里面添加文件。而View分组包含内容如下:

  • RWTFlickSearchViewController:应用的主界面,包含一个搜索文本框和“Go”按钮。
  • RWTRecentSearchItemTableViewCell:在主屏幕中展示最新的搜索结果的列表单元格。
  • RWTSearchResultsViewController:搜索结果界面,展示来自Flickr的图片列表。
  • RWTSearchResultsTableViewCell:展示单幅Flickr图片的列表单元格

现在可以添加你的第一个ViewModel了!

你的第一个ViewModel

在ViewModel分组下添加一个新类,命名为RWTFlickrSearchViewModel并继承自NSObject

打开新添加的头文件,并往里面添加如下两个属性:

@interface RWTFlickrSearchViewModel : NSObject
 
@property (strong, nonatomic) NSString *searchText;
@property (strong, nonatomic) NSString *title;
 
@end

searchText属性代表着文本框显示的文本,而title属性则表示导航条中的显示标题。

注意:为了更容易理解应用的架构,View和ViewModel都共用一个只有后缀不同的名字。比如RWTFlickrSearch-ViewModelRWTFlickrSearch-ViewController

打开RWTFlickrSearchViewModel.m并添加以下代码:

@implementation RWTFlickrSearchViewModel
 
- (instancetype)init {
  self = [super init];
  if (self) {
    [self initialize];
  }
  return self;
}
 
- (void)initialize {
  self.searchText = @"search text";
  self.title = @"Flickr Search";
}
 
@end

这简单的设置了ViewModel的初始状态。

下一步是将ViewModel和View关联起来。记得View持有ViewModel的引用。在这种情况下,最好为View添加初始化方法,赋予对应的ViewModel。

注意:在本教程中我们把控制器也视为“View”的一部分,以更符合MVVM中“View”的语义。这有别于UIKit中一贯的用法。

打开RWTFlickrSearchViewController.h并导入ViewModel的头文件:

#import "RWTFlickrSearchViewModel.h"

然后添加初始方法如下:

@interface RWTFlickrSearchViewController : UIViewController
 
- (instancetype)initWithViewModel:(RWTFlickrSearchViewModel *)viewModel;
 
@end

RWTFlickrSearchViewController.m类扩展的UI引用后添加私有属性如下:

@property (weak, nonatomic) RWTFlickrSearchViewModel *viewModel;

然后添加初始方法如下:

- (instancetype)initWithViewModel:(RWTFlickrSearchViewModel *)viewModel {
  self = [super init];
  if (self ) {
    _viewModel = viewModel;
  }
  return self;
}

这存储了这个View对应的ViewModel引用。

注意:这是一个弱引用;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添加引用如下:

#import "RWTFlickrSearchViewModel.h"

并添加私有属性(在文件上方的类扩展中):

@property (strong, nonatomic) RWTFlickrSearchViewModel *viewModel;

你会发现这个类中已经有一个createInitialViewController方法,更新它的实现如下:

- (UIViewController *)createInitialViewController {
  self.viewModel = [RWTFlickrSearchViewModel new];
  return [[RWTFlickrSearchViewController alloc] initWithViewModel:self.viewModel];
}

这创建了一个ViewModel的实例,然后构造返回了View。这就是应用导航控制器的初始视图。

编译运行,现在视图已经具有了一些新的状态。


恭喜,这就是你的第一个ViewModel。但请先收敛一下你的兴奋之情,接下来还有很多东西要学呢 ; ]

你可能已经发现,你还没有使用任何的ReactiveCocoa。在现在的情况下,用户在搜索文本框输入的任何内容都不会反映到ViewModel中。

检测搜索有效性

在这个部分,你将会使用ReactiveCocoa绑定ViewModel和View,以便把搜索文本框和按钮都与ViewModel关联起来。

RWTFlickrSearchViewController.m更新bindViewModel方法如下:

- (void)bindViewModel {
  self.title = self.viewModel.title;
  RAC(self.viewModel, searchText) = self.searchTextField.rac_textSignal;
}

ReactiveCocoa使用category为UITextField添加了rac_textSignal属性。这个信号会在文本框更新时发送一个包含现有文本的next事件。

RAC宏是一个绑定操作。上面的代码会在rac_textSignal发送的next事件时用里面的内容更新viewModelsearchText属性。

简单来说,这保证了searchText总是反映着当前的UI状态。如果上面的内容让你觉得云里雾里,那么你真的需要复习一下之前的两篇ReactiveCocoa教程了!

搜索按钮应该只有在用户输入的文本有效时才可用。为了简化流程,我们暂时规定只有当输入的文本超过三个字节时才能执行搜索。

RWTFlickrSearchViewModel.m添加导入如下:

#import <ReactiveCocoa/ReactiveCocoa.h>

之后更新初始化方法如下:

- (void)initialize {
  self.title = @"Flickr Search";
 
  RACSignal *validSearchSignal =
    [[RACObserve(self, searchText)
      map:^id(NSString *text) {
         return @(text.length > 3);
      }]
      distinctUntilChanged];
 
  [validSearchSignal subscribeNext:^(id x) {
    NSLog(@"search text is valid %@", x);
  }];
}

编译运行并在文本框中任意输入一些内容。现在当文本内容在有效和无效间转换时你就能在控制台看到打印信息:

2014-05-27 18:03:26.299 RWTFlickrSearch[13392:70b] search text is valid 0
2014-05-27 18:03:28.379 RWTFlickrSearch[13392:70b] search text is valid 1
2014-05-27 18:03:29.811 RWTFlickrSearch[13392:70b] search text is valid 0

上面的代码使用RACObserve宏为ViewModel的searchText属性创建了一个信号(这是ReactiveCocoa对KVO的封装)。随后map操作将文本转化为值为布尔值的流。

最后,distinctUntilChanges方法用于保证该信号只有在状态变更时才会发送新值。

注意:如果你在过程中感到困难,尝试将这个过程分解。先添加RACObserve宏,打印输出,然后再添加map操作和distinctUntilChanged方法。

至今ReactiveCocoa主要用于绑定View和ViewModel,以确保两者保持同步。而除此之外,ReactiveCocoa也在ViewModel的内部用于观察它自身的state和执行其他操作。

这种模式在本教程随处可见。ReactiveCocoa在View与ViewModel的绑定中至关重要的同时,对应用的其他层级起着巨大的作用。

添加搜索命令

在这个部分,你会为validSearchSignal:方法添加更加实用的实现,用以创建视图相关的命令。
打开RWTFlickrSearchViewModel.h并添加以下导入:

#import <ReactiveCocoa/ReactiveCocoa.h>

和属性如下:

@property (strong, nonatomic) RACCommand *executeSearch;

RACCommand是ReactiveCocoa中一个表示UI操作的概念。它包含UI操作的结果和当前操作是否正在执行的状态。

RWTFlickrSearchViewModel.m的初始化方法中添加代码如下:

self.executeSearch =
  [[RACCommand alloc] initWithEnabled:validSearchSignal
    signalBlock:^RACSignal *(id input) {
      return  [self executeSearchSignal];
    }];

这创建了一个当validSearchSignal发送true时有效的命令。

在同一文件的下,添加用于生成命令中执行信号的方法如下:

- (RACSignal *)executeSearchSignal {
  return [[[[RACSignal empty]
           logAll]
           delay:2.0]
           logAll];
}

在这个方法中,你将实现一些在命令执行时处理的业务逻辑,并通过信号异步地返回结果。

上面的方法中暂时还是假实现,空的信号会立即结束。延迟操作为所有接受到的nextcompleted事件添加了两秒的延迟。这是让代码执行显得更为真实的巧妙方法。

注意:上面的信号管道有两个附加的logAll操作,那是副作用(side effects)的一种,用于打印通过的所有事件。
这在测试ReactiveCocoa代码时相当有用,当然如果你认为它们并不必要的话,可以随时移除掉这些打印操作。

最后要将这个命令与视图关联起来。打开RWTFlickrSearchViewController.m并在bindViewModel方法的末端添加代码如下:

self.searchButton.rac_command = self.viewModel.executeSearch;

rac_command是ReactiveCocoa对UIButton的扩充属性。上面的代码让按钮在点击时执行对应的命令,并把按钮的可用状态与命令的可用状态关联起来。

编译运行,任意输入一点文本后点击“Go”:


你会看到按钮只有在文本框中多与3个字符时有效。而且,你点击按钮时,按钮将在两秒内不可用,直到执行信号完成后才重新变为可用。

而在控制台中,你还可以看到空信号立马结束,延迟操作让这事件在延迟两秒后发出:

09:31:25.728 RWTFlickrSearch ... name: +empty completed
09:31:27.730 RWTFlickrSearch ... name: [+empty] -delay: 2.0 completed

绑定,绑定和更多的绑定

RACCommand负责了搜索按钮的状态更新,而活动指示器(activity indicator)的可见与否则需要你来处理。

RACCommand提供了一个executing属性,这是一个发送true或false事件的信号,用以表明命令是否正在执行。你可以在应用的其他地方使用这个属性反映当前命令的状态。

RWTFlickrSearchViewController.mbindViewModel的方法末端添加代码如下:

RAC([UIApplication sharedApplication], networkActivityIndicatorVisible) =
  self.viewModel.executeSearch.executing;

这绑定了UIApplicationnetworkActivityIndicatorVisible属性和命令的executing信号。这保证了每当命令执行时,状态栏中小型的网络活动指示器就会显示。

然后继续添加代码如下:

RAC(self.loadingIndicator, hidden) =
  [self.viewModel.executeSearch.executing not];

当命令执行完成时,加载指示符应该隐藏起来。hiddenexecuting在逻辑上是刚好相反的。

很幸运,ReactiveCocoa为这种情况提供了一个not方法去翻转信号的结果。

最后再添加代码如下:

[self.viewModel.executeSearch.executionSignals
  subscribeNext:^(id x) {
    [self.searchTextField resignFirstResponder];
  }];

这保证了命令执行时会把键盘隐藏起来。executionSignals属性发送每次命令执行时生成的信号(译注:即代码中的x为命令生成的信号)。

这属性是一个信号中的信号(在早前的教程中介绍过)。即当一个新的命令执行信号被创建并发送,键盘就会隐藏。

现在编译运行应用,实际检验一下上面的代码。

Model在哪?

至今为止你已经明确的定义了View(RWTFlickrSearchViewController)和ViewModel(RWTFlickrSearchViewModel),但是,额,Model在哪?

答案非常简单:还没有!

现在应用会在用户点击搜索按钮时执行一个命令,但具体内容还没实现。

需要做的是在ViewModel中用当前的searchText属性搜索Flickr,并返回一个匹配图片的列表。

你可以直接在ViewModel中添加这个逻辑,但相信我,你会后悔的!如果这是一个视图控制器,我赌你一定就这么干了。

ViewModel提供了代表UI状态的属性和代表UI操作的命令(通常是方法)。其负责管理用户交互导致的UI状态变更。

但是,ViewModel不应该处理因为交互执行的具体的业务逻辑。那是Model的工作。

在下一步,你将在应用中创建一个Model层。里面会包含一些“脚手架代码”(scaffolding code),你只要跟着教程做,你很快就会发现更有趣的东西。

在Model分组下,添加一个名为RWTFlickrSearch的协议,并实现如下:

#import <ReactiveCocoa/ReactiveCocoa.h>
@import Foundation;
 
@protocol RWTFlickrSearch <NSObject>
 
- (RACSignal *)flickrSearchSignal:(NSString *)searchString;
 
@end

这个代理定义了Model层的初始接口,并将搜索Flickr的责任从ViewModel中分离出来。

接下来在同一个分组中添加一个NSObject的子类,名为RWTFlickrSearchImpl,并遵守刚添加的协议:

@import Foundation;
#import "RWTFlickrSearch.h"
 
@interface RWTFlickrSearchImpl : NSObject <RWTFlickrSearch>
 
@end

打开RWTFlickrSearchImpl.m并实现如下:

@implementation RWTFlickrSearchImpl
 
- (RACSignal *)flickrSearchSignal:(NSString *)searchString {
  return [[[[RACSignal empty]
            logAll]
            delay:2.0]
            logAll];
}
 
@end

对此你有没有感到似曾相识?如果有,那是因为这是与先前在ViewModel中相同的假实现。

下一步就要把Model层在ViewModel中用起来。在ViewModel分组中添加名为RWTViewModelServices协议如下:

@import Foundation;
#import "RWTFlickrSearch.h"
 
@protocol RWTViewModelServices <NSObject>
 
- (id<RWTFlickrSearch>) getFlickrSearchService;
 
@end

这协议定义了一个方法,用以允许ViewModel获取一个遵守RWTFlickrSearch协议的实例引用。

打开RWTFlickrSearchViewModel.h并引入这个新协议:

#import "RWTViewModelServices.h"

并添加以这为入参的初始化方法:

- (instancetype) initWithServices:(id<RWTViewModelServices>)services;

RWTFlickrSearchViewModel.m中,添加类扩展和指向服务引用的私有属性:

@interface RWTFlickrSearchViewModel ()
 
@property (nonatomic, weak) id<RWTViewModelServices> services;
 
@end

在同一个文件下,更新初始化方法(译注:原init方法)如下:

- (instancetype) initWithServices:(id<RWTViewModelServices>)services {
  self = [super init];
  if (self) {
    _services = services;
    [self initialize];
  }
  return self;
}

这简单地存储了服务的引用。

最后,更新executeSearchSignal方法如下:
Finally, update the executeSearchSignal method as follows.

- (RACSignal *)executeSearchSignal {
  return [[self.services getFlickrSearchService]
           flickrSearchSignal:self.searchText];
}

以上的方法现在委托Model去进行搜索工作。

最后一步就是连接Model和ViewModel。

在项目的项目根分组中添加一个命名为RWTViewModelServicesImplNSObject子类。打开RWTViewModelServicesImpl.h并遵守RWTViewModelServices协议:

@import Foundation;
#import "RWTViewModelServices.h"
 
@interface RWTViewModelServicesImpl : NSObject <RWTViewModelServices>
 
@end

打开RWTViewModelServicesImpl.m并实现如下:

#import "RWTViewModelServicesImpl.h"
#import "RWTFlickrSearchImpl.h"
 
@interface RWTViewModelServicesImpl ()
 
@property (strong, nonatomic) RWTFlickrSearchImpl *searchService;
 
@end
 
@implementation RWTViewModelServicesImpl
 
- (instancetype)init {
  if (self = [super init]) {
    _searchService = [RWTFlickrSearchImpl new];
  }
  return self;
}
 
- (id<RWTFlickrSearch>)getFlickrSearchService {
  return self.searchService;
}
 
@end

这个类简单地创建了一个RWTFlickrSearchImpl实例,用以搜索Flickr的Model层服务,并按需提供给ViewModel。

最后,打开RWTAppDelegate.m并添加导入如下:

#import "RWTViewModelServicesImpl.h"

并添加一个新的私有属性:

@property (strong, nonatomic) RWTViewModelServicesImpl *viewModelServices;

然后更新createInitialViewController方法如下:

- (UIViewController *)createInitialViewController {
  self.viewModelServices = [RWTViewModelServicesImpl new];
  self.viewModel = [[RWTFlickrSearchViewModel alloc]
                    initWithServices:self.viewModelServices];
  return [[RWTFlickrSearchViewController alloc]
          initWithViewModel:self.viewModel];
}

编译运行,并确保应用像先前一样运行无误。

但这不是这些改变最激动人心的地方,看一下这些新代码的构成。

Model层提供了一个ViewModel使用的“服务”。而一个协议定义了这个服务的接口,实现了松耦合。

你可以用这个方式去为单元测试提供一个假的服务实例。应用现在已经是真正的Model-View-ViewModel结构了。简单总结一下:

  1. Model层提供服务并负责应用的业务逻辑。在本例中,它提供了搜索Flickr的服务。
  2. ViewModel代表应用的视图状态。它同时也响应用户交互和处理Model层的事件,并反映在视图状态的改变上。
  3. View层则非常轻量,只简单提供了ViewModel状态的可视化和转发用户交互。

注意:在本应用中,Model层使用ReactiveCocoa的信号提供了服务。这个框架强大之处可并不仅仅在于绑定!

搜索Flickr

在这个部分,你将要真正实现Flickr的搜索,是的,事情变得越来越有趣了;]

第一步先创建代表搜索结果的Model对象。

在Model分组下,添加一个新的NSObject子类,名为RWTFlickrPhoto,并添加3个属性如下:

@interface RWTFlickrPhoto : NSObject
 
@property (strong, nonatomic) NSString *title;
@property (strong, nonatomic) NSURL *url;
@property (strong, nonatomic) NSString *identifier;
 
@end

这个Model对象代表Flickr搜索接口返回的单幅照片数据。

打开RWTFlickrPhoto.m文件并添加describe方法实现:

- (NSString *)description {
  return self.title;
}

这方便你在变更UI之前先在控制台测试打印搜索实现的结果。

然后,添加另一个Model对象RWTFlickrSearchResults,同为NSObject的子类,其中属性如下:

@import Foundation;
 
@interface RWTFlickrSearchResults : NSObject
 
@property (strong, nonatomic) NSString *searchString;
@property (strong, nonatomic) NSArray *photos;
@property (nonatomic) NSUInteger totalResults;
 
@end

这代表着一个Flickr搜索返回的照片集合。

打开RWTFlickrSearchResults.m并添加describe方法实现(同样为了方便日志打印):

- (NSString *)description {
  return [NSString stringWithFormat:@"searchString=%@, totalresults=%lU, photos=%@",
          self.searchString, self.totalResults, self.photos];
}

接下来是时候编写搜索Flickr的代码了!

打开RWTFlickrSearchImpl.m并添加导入如下:

#import "RWTFlickrSearchResults.h"
#import "RWTFlickrPhoto.h"
#import <objectiveflickr/ObjectiveFlickr.h>
#import <LinqToObjectiveC/NSArray+LinqExtensions.h>

这导入了你刚创建的Model对象和两个CocoaPods加载的外部引用:

  • ObjectiveFlickr:这是基于Objective-C封装的Flickr接口库。它负责处理相关授权和解析接口返回。相比直接使用Flickr的接口,使用这个库会更为简单。
  • LinqToObjectiveC:这个库提供了一系列流畅的函数式接口,用于对数组和字典进行查询,过滤和转换。

继续在RWTFlickrSearchImpl.m中添加类扩展如下:

@interface RWTFlickrSearchImpl () <OFFlickrAPIRequestDelegate>
 
@property (strong, nonatomic) NSMutableSet *requests;
@property (strong, nonatomic) OFFlickrAPIContext *flickrContext;
 
@end

这遵守了来自ObjectiveFlickr库的OFFlickrAPIRequestDelegate协议,并添加了两个私有属性。你很快就能看到这是如何使用的。

在同一个文件下添加如下初始化方法如下:

- (instancetype)init {
  self = [super init];
  if (self) {
    NSString *OFSampleAppAPIKey = @"YOUR_API_KEY_GOES_HERE";
    NSString *OFSampleAppAPISharedSecret = @"YOUR_SECRET_GOES_HERE";
 
    _flickrContext = [[OFFlickrAPIContext alloc] initWithAPIKey:OFSampleAppAPIKey
                                                  sharedSecret:OFSampleAppAPISharedSecret];
    _requests = [NSMutableSet new];
  }
  return  self;
}

这创建了一个Flickr的“上下文”,用以储存ObjectiveFlickr生成接口请求的必须数据。

注意:要使用ObjectiveFlickr,你要在Flickr App Garden中创建一个Flickr的应用key。那免费而且只有简答的几个步骤。
注意申请的是非商业用途的key。

ObjectiveFlickr接口相当典型。你创建接口请求,结果的成功与否将通过代理方法返回,具体方法定义在先前提到的OFFlickrAPIRequestDelegate协议中。

现有由Model层提供的,定义在RWTFlickrSearch协议中的接口,只有一个根据搜索字符串搜索图片的方法。

然而,接下来你还需要添加更多的方法。

正因如此,为了优化代码,你将直接用更加聪明的方式,使用信号改造这些基于代理的接口。

继续在RWTFlickrSearchImpl.m中添加方法如下:

- (RACSignal *)signalFromAPIMethod:(NSString *)method
                         arguments:(NSDictionary *)args
                         transform:(id (^)(NSDictionary *response))block {
 
  // 1. Create a signal for this request
  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
 
    // 2. Create a Flick request object
    OFFlickrAPIRequest *flickrRequest =
      [[OFFlickrAPIRequest alloc] initWithAPIContext:self.flickrContext];
    flickrRequest.delegate = self;
    [self.requests addObject:flickrRequest];
 
    // 3. Create a signal from the delegate method
    RACSignal *successSignal =
      [self rac_signalForSelector:@selector(flickrAPIRequest:didCompleteWithResponse:)
                     fromProtocol:@protocol(OFFlickrAPIRequestDelegate)];
 
    // 4. Handle the response
    [[[successSignal
      map:^id(RACTuple *tuple) {
        return tuple.second;
      }]
      map:block]
      subscribeNext:^(id x) {
        [subscriber sendNext:x];
        [subscriber sendCompleted];
      }];
 
    // 5. Make the request
    [flickrRequest callAPIMethodWithGET:method
                              arguments:args];
 
    // 6. When we are done, remove the reference to this request
    return [RACDisposable disposableWithBlock:^{
      [self.requests removeObject:flickrRequest];
    }];
  }];
}

这个方法根据方法名和传递参数发起了一个接口请求,并使用提供的block参数转换接口返回。你很快就能看到这是如何运作的。

这个方法相当长,我们按步骤分析一下:

  1. createSignal方法创建了一个新信号。传递给block的subscriber参数用于发送next, error 和completed事件给信号的订阅者。
  2. 一个ObjectiveFlickr的请求被构建,这个请求的引用被保存到requests集合中。没有这行代码,OFFlickrAPIRequest将不作保留!
  3. rac_signalForSelector:fromProtocol:方法根据代理方法创建了一个信号,用于表明Flickr接口请求的结果。
  4. 订阅这个信号(代理方法生成的),并转换结果,然后作为事件值发送给第一步创建的信号(之后我们还会详细谈谈这个)
  5. 调用ObjectiveFlickr的接口请求。
  6. 当信号处理完成,这个block保证了Flickr请求的引用会被移除,避免了内存泄漏。

现在我们重点看一下第四部:

[[[successSignal
  // 1. Extract the second argument
  map:^id(RACTuple *tuple) {
    return tuple.second;
  }]
  // 2. transform the results
  map:block]
  subscribeNext:^(id x) {
    // 3. send the results to the subscribers
    [subscriber sendNext:x];
    [subscriber sendCompleted];
  }];

rac_signalForSelector:fromProtocol:方法创建了successSignal信号,它同时也在代理方法的调用中创建了信号。

每当代理方法被调用,一个next事件就会发送,其中附带一个包含方法入参的RACTuple。而上面的管道则进行了如下几步处理:

  1. map方法从flickrAPIRequest:didCompleteWithResponse:代理方法中提取了它的第二个NSDictionary类型的参数。
  2. block作为参数传递给第二个map方法,用以转换结果。你接下来能看到这是如何把字典转换成model对象的。
  3. 最后,转换的结果作为next事件被发送,信号完成结束。

注意:这段代码有一个小小的问题,考虑一下,如果当有多个并发请求时会发生什么?在这个MVVM教程的下半部分你将解决这个问题,但如果你喜欢挑战自己的话,何不现在就尝试解决一下呢?

最后一步就是实现Flickr的搜索方法如下:

- (RACSignal *)flickrSearchSignal:(NSString *)searchString {
  return [self signalFromAPIMethod:@"flickr.photos.search"
                         arguments:@{@"text": searchString,
                                     @"sort": @"interestingness-desc"}
                         transform:^id(NSDictionary *response) {
 
    RWTFlickrSearchResults *results = [RWTFlickrSearchResults new];
    results.searchString = searchString;
    results.totalResults = [[response valueForKeyPath:@"photos.total"] integerValue];
 
    NSArray *photos = [response valueForKeyPath:@"photos.photo"];
    results.photos = [photos linq_select:^id(NSDictionary *jsonPhoto) {
      RWTFlickrPhoto *photo = [RWTFlickrPhoto new];
      photo.title = [jsonPhoto objectForKey:@"title"];
      photo.identifier = [jsonPhoto objectForKey:@"id"];
      photo.url = [self.flickrContext photoSourceURLFromDictionary:jsonPhoto
                                                              size:OFFlickrSmallSize];
      return photo;
    }];
 
    return results;
  }];
}

上面的代码使用了你在上一步添加的signalFromAPIMethod:arguments:transform:方法。使用flickr.photos.search接口方法搜索照片,搜索条件以字典的形式传入。

作为transform参数传入的block把NSDictionary类型的返回转换成对等的model对象,以便于在ViewModel中使用。

这段代码使用了LinqToObjectiveC添加到NSArray中的linq_select方法,以函数式的接口进行数组转换。

注意:对于更加复杂的JSON转对象操作,我推荐使用 Mantle。尽管在这个特殊的model中,要正确映射则需要用到它的2.0特性

最后,打开RWTFlickrSearchViewModel.m,并更新搜索信号,打印它的搜索结果。

- (RACSignal *)executeSearchSignal {
  return [[[self.services getFlickrSearchService]
           flickrSearchSignal:self.searchText]
           logAll];
}

编译运行并输入搜索字符,查看这个信号打印在控制台的结果:

2014-06-03 [...] <RACDynamicSignal: 0x8c368a0> name: +createSignal: next: searchString=wibble, totalresults=1973, photos=(
    "Wibble, wobble, wibble, wobble",
    "unoa-army",
    "Day 277: Cheers to the freakin' weekend!",
    [...]
    "Angry sky",
    Nemesis
)

注意:如果你没有获取到结果,那么请再次检查你的Flickr接口秘钥和分享密码(Flickr API key and shared secret)。

至此这个MVVM教程的上半部分即将结束,而在结束之前,当前应用代码中还有一个非常重要的方面你是没有顾及到的……

内存管理

我曾在先前的教程中提到,在你使用基于block的ReactiveCocoa接口时要小心避免引用循环,那最终会导致内存的泄漏。

如果一个block中使用了“self”,而“self”又强引用了该block,就会造成引用循环。而在早前的教程中你看到了如何使用@weakify@strongify宏去打破这些引用循环。

那你有否因为signalFromAPIMethod:arguments:transform:在引用self时没有使用这些宏而感到疑惑?

那是因为那个block是作为参数传入到createSignal:方法中的,那并不会在self和block之间构成强引用。

很混乱?好吧,你不一定要相信我的话,就直接测试这代码去看看有没内存泄漏好了。

使用Product / Profile选项运行引用,选中Allocations配置。当应用开始运行,使用右上角的过滤器搜索包含“OFF”的类,比如ObjectiveFlickr框架中的类。(译注:在最新的Xcode 8.3.1版本,该过滤输入框在左下角。)

你会发现在应用启动时创建了一个单例OFFlickrAPIContext。当开始搜索时,一个OFFlickrAPIRequest的实例被创建并在搜索期间一直存在:


好消息是,当接口请求结束,OFFlickrAPIRequest实例就会被回收。这证明了发起Flickr请求的block并没有被持有!

我建议你在应用开发中周期性地重复这项分析操作,以保证内存堆中只有你期望的对象。

何去何从?

这个示例项目包含了这个教程至今的所有代码。这总结了这个MVVM上半部教程的所有内容!

在教程下一部分,你将关注与如何从ViewModel转换视图控制器,和实现更多的Flickr接口去丰富应用的功能。

译注:下半部原文不知何种原因错漏较多,故而不发布翻译。在第二部分中,主要涉及的是通过ViewModel层进行视图控制器的跳转。关于这个话题,建议阅读MVVM With ReactiveCocoa和研究其例子中的源码。里面的实现更为全面。

推荐阅读更多精彩内容