reactivecocoa in practice

翻译自:Functional Reactive Programming on iOS – Ash Furrow. 版权所有, 转载请著明出处,保留链接。

##reactivecocoa in pratice
本章,我们将在实际的项目中应用ReactiveCocoa,将构建一个简单的500px iphone 应用程序。500px 像Flickr,集聚最好的照片。我喜欢使用它们的API有两个原因:其一,它们的照片看上去很棒;其二,我曾经在哪工作过,从事iOS SDK API的编写工作,所以,我非常熟悉它们的API。
我们将分三部分讲这章。第一,我们将构建一个基本的app实现,叫FunctionalReactivePixels;第二,我们会增加新的视图开发数据加载模块来更好的展现app; 第三,我们会监视app,尽可能的减少状态变量,而使用函数反应式编程。
这将是非常有意思的一章,你可以再GitHub找到我们开发的FunctionReactivePixels app 源码。不幸的是,GitHub源码中有些工作未完成,不过,你跟随这章,你会知道怎么做的。

###Basics of FunctionalReactivePixels
FuncionalReactivePixels是一款简单的app, 它浏览500px上的热点的照片。学完这节,我们的app主视图将看起来如下.

1

我们也有一个完整大小的图片视图如下。

2

这个app将会使用集合视图(collection views)。如果你对集合视图没有使用经验,也请别担心,因为它们和表视图一样,使用起来非常的直接。如果你想更深入的了解,请读一下我的另一本书
我们将再次使用CocoaPods来管理库依赖,先创建一个Xcode工程,我喜欢使用空模板工程,因为能够完全的控制视图器的继承关系。

3

首先创建一个UICollectionViewController的子类FRPGalleryViewController,并也创建一个UICollectionFlowLayout的子类FRPGalleryFlowLayout。
在FRPGalleryViewController的实现文件中#import展示头文件,并且重写FRPGalleryViewController的init方法。

-(id)init
{
    FRPGalleryFlowLayout *flowLayout = [[FRPGalleryFlowLayout alloc] init];

    self = [self initWithCollectionViewLayout:flowLayout];
    if (!self) return nil;

    return self;
}

这会初始化集合视图的布局为RPGalleryFlowLayout,其实现也非常简单,只设置了自身的一些属性。

@implementation FRPGalleryFlowLayout

-(instancetype)init{

    if (!(self = [super init])) return nil;

    self.itemSize = CGSizeMake(145, 145);
    self.minimumInteritemSpacing = 10;
    self.minimumLineSpacing = 10;
    self.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);
    return self;
}

@end

下一步,将在屏幕上显示我们的视图控制器。为了能这样做,请先找到应用程序代理文件application:didFinishLaunchingWithOptions:方法,我们想在我们的集合视图控制器中包含一个导航控制。

-(BOOL)application:(UIApplication:)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    self.window.rootViewController = [[UINavigationController alloc]
        initWithRootViewController:[[FRPGalleryViewController alloc] init]];

    self.window.backgroundColor = [UIColor whiteColor]; [self.window makeKeyAndVisible];
    return YES;
}

很好,你现在运行app, 将会看到下面那样的空白视图。

4

让我们在里面填充些内容。首先创建一个Podfile文件,在文件中加入如下内容:

platform:ios,"7.0"

target "FRP" do

pod 'ReactiveCocoa','2.1.4'
pod 'libextobjc','0.3'
pod '500px-iOS-api','1.0.4'
pod 'SVProgressHUD','0.9'

end

target "FRPTests" do

end

我们将在下一章增加些测试代码,但是现在,请运行pod install并打开产生的Xcode工作空间。打开预编译头文件(FRP-Prefix.pch)并插入如下行代码,这些代码将自动的增加到工程中的每个文件中。

//Pods
#import <ReactiveCocoa/ReactiveCocoa.h>
#import <500px-iOS-api/PXAPI.h>
#import <libextobjc/EXTScope.h>

//AppDelegate
#import "FRPAppDelegate.h"
#define AppDelegate ((FRPAppDelegate *)[[UIApplication sharedApplication] delegate])

在app代理上创建个属性来存储对500px发起请求的客户端。

@property(nonatomic,readonly)PXAPIHelper*apiHelper;

下一步,在方法application: didFinishLaunchingWithOptions: 中实例化它。

self.apiHelper = [[PXAPIHelperalloc]
    initWithHost:nil
    consumerKey:@"DC2To2BS0ic1ChKDK15d44M42YHf9gbUJgdFoF0m"
    consumerSecret:@"i8WL4chWoZ4kw9fh3jzHK7XzTer1y5tUNvsTFNnB"];

我提供了访问500px的密匙和密钥,你可通过(注册)[http://500px.com/settings/applications]得到它们。
现在了,我们能做些加载工作。我们需要一个模型对象来保存数据,我创建了FRPPhotoModel,它的实现为空.

@interfaceFRPPhotoModel:NSObject

    @property(nonatomic,strong) NSString *photoName; 
    @property(nonatomic,strong) NSNumber *identifier; 
    @property(nonatomic,strong) NSString *photographerName; 
    @property(nonatomic,strong) NSNumber *rating; 
    @property(nonatomic,strong) NSString *thumbnailURL; 
    @property(nonatomic,strong) NSData *thumbnailData; 
    @property(nonatomic,strong) NSString *fullsizedURL; 
    @property(nonatomic,strong) NSData *fullsizedData;

@end

我们不在视图控制器中直接的加载内容,而把这个实现抽象在另一个类中。穿件一个NSObject的子类叫FRPPhotoImporter。
现在的任何代码都不是函数式的。别担心,我们很快会接触到的。照片加载器(photo importer)将不返回一个FRPPhotoModel对象,相反,它会返回一个信号,信号上承载着最近的API调用结果。

@interface FRPPhotoImporter:NSObject 

+(RACSignal*)importPhotos;

@end

这种情况下,RACSignal是一个RACReplaySubject对象,但是根据在使用RACSubjects的ReactiveCocoa建议, 我们在头文件中声明的返回类型是一个信号signal,但在实现文件中是一个subject,我们来看看。

+(RACReplaySubject*)importPhotos{
    RACReplaySubject *subject = [RACReplaySubject subject];

    NSURLRequest *request = [self popularURLRequest];

    [NSURLConnection
        sendAsynchronousRequest:request
        queue:[NSOperationQueue mainQueue]
        completionHandler:^(NSURLResponse *response,
            NSData *data, NSError *connectionError) {

        if (data) {
            id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];

            [subject sendNext:[[[results[@"photos"] rac_sequence] map: ^id(NSDictionary *photoDictionary) {
                FRPPhotoModel *model = [FRPPhotoModel new];

                [self configurePhotoModel:model withDictionary:photoDictionary];
                [self downloadThumbnailForPhotoModel:model];

                return model; 
            }] array]];

            [subject sendCompleted];
        }
        else {
            [subject sendError:connectionError];
        } 
    }];

    return subject; 
}

这段代码做了很多事情,让我们来仔细的研究下。首先,我们创建了一个新的RACReplaySubject的实例,这将是我们的返回值。然后我们创建了一个NSURLRequest对象去访问500px的热点图片。接着,我们异步的发送了请求,并且马上返回了subject对象,注意这里是立即返回,而不是等请求完成后再返回的。
目标(subject)将在异步网络请求的回调函数块的作用域里被捕获到,当网络数据返回,回调被触发,目标(subject)将会发送值。那么谁订阅了目标(subject)就会收到这些值。
这是在操作异步时,常见的模式。

  1. 创建一个目标(subject)
  2. 在异步回调块中发送目标(subject)的值。
  3. 立即返回目标(subject).

注意区分RACSubject和其子类RACReplaySubject的不同。RACReplaySubject只能被订阅一次,这避免了重复的重做。replay subject会存储返回的值,并把他们发送给新的订阅者-这正是我们需要的。正如ReactiveCocoa的开发者Spahr-Summers所指出的,这避免了竞态。
我们发送了completed数据,而不是一个信号流的值。如果我们发送照片模型流,这将更具有反应式,并且随后它能够被拼接起来。这对分页也非常有意义,但是我们不打算讲这种模式,因为这有点高深。请看看octokit拼接例子。
URL请求的构建方法如下:

+(NSURLRequest*)popularURLRequest{
    return [AppDelegate.apiHelper urlRequestForPhotoFeature:PXAPIHelperPhotoFeaturePopular \
                resultsPerPage:100 page:0 photoSizes:PXPhotoModelSizeThumbnail \
                sortOrder:PXAPIHelperSortOrderRating except:PXPhotoModelCategoryNude];
}

目标(subject)发送了什么?这依赖于回调块,来好好看看。

if(data){
    id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];

    [subject sendNext:[[[results[@"photos"] rac_sequence] map:^id(NSDictionary *photoDictionary) { 
        FRPPhotoModel *model = [FRPPhotoModel new];

        [self configurePhotoModel:model withDictionary:photoDictionary];
        [self downloadThumbnailForPhotoModel:model];

        return model; 
    }] array]];

    [subject sendCompleted];
}
else{
    [subject sendError:connectionError];
}

我们测试了数据是否会返回。其实这不是判定错误条件的最好方法,但是这是一个示范性的例子。如果data是nil,我们将返回一个错误。否则,我们解析JSON数据。我们来看看它是怎么做的。

[subject sendNext:[[[results[@"photos"] rac_sequence] map:^id(NSDictionary *photoDictionary) { 
    FRPPhotoModel *model = [FRPPhotoModel new];

    [self configurePhotoModel:model withDictionary:photoDictionary];
    [self downloadThumbnailForPhotoModel:model];

    return model; 
}] array]];

[subject sendCompleted];

解构下第一个表达式,我们随subject发送了一个值,其实是照片集合的数组,然后把它们转换成一个序列,做映射操作后,再转换成一个数组。这个技术过程同上一章看到的map:方法相似。

这个映射过程非常有意思。对于序列中的每个值,一个新的FRPPhotoModel对象被创建,配置,和返回。根据[@”photos”]数组中的每个值创建相应的照片模型数组。最后,我们会发送完成值告诉订阅者已经把值准备好了。

5

注意能手动的沿着信号发送值只有RACSubject实例才有这个能力。
configurePhotoModel:withDictionary:方法如下实现:

+(void)configurePhotoModel:(FRPPhotoModel *)photoModel withDictionary:(NSDictiona\ry *)dictionary {
    // Basics details fetched with the first, basic request
    photoModel.photoName = dictionary[@"name"];
    photoModel.identifier = dictionary[@"id"];
    photoModel.photographerName = dictionary[@"user"][@"username"];
    photoModel.rating = dictionary[@"rating"];

    photoModel.thumbnailURL = [self urlForImageSize:3 inArray:dictionary[@"images"]];
    // Extended attributes fetched with subsequent request
    if (dictionary[@"comments_count"]) {

        photoModel.fullsizedURL = [self urlForImageSize:4 inArray:dictionary[@"images"]];
    }
}

这些语句都非常基础,除了设置URL属性,它还依赖另一个方法去解析从500px API返回的图像链表,数据格式如下。

(
    {
        size = size;
        url = ...;
    }
);

所以数据格式是一个包含字典的数组,每个字典对象宝航一个图像图像和一个相对应的URL。解析方法实现如下。

+(NSString *)urlForImageSize:(NSInteger)size inDictionary:(NSArray *)array {

    return [[[[[array rac_sequence] filter:^BOOL(NSDictionary *value) {
        return [value[@"size"] integerValue] == size;
    }] map:^id(id value) {
        return value[@"url"];
    }] array] firstObject];
}

这里有一个隐藏的错误处理实现,如果序列为空,那么数组NSArray的firstObject方法返回nil。开始,我们过滤了字典中size参数不符合要求的。然后利用映射map:从字典中提取url的值,结果得到一个序列的NSString对象,最后转换成数组,并返回第一个对象。

6

在ReactiveCocoa中,信号的链式操作很常见。值从rac_sequence中推进filter:方法,最后推进map:方法。调用array方法把序列转换成数组返回。
最后,我们来看一下downloadThumbnailForPhotoModel:方法的实现.

+(void)downloadThumbnailForPhotoModel:(FRPPhotoModel *)photoModel {
    NSAssert(photoModel.thumbnailURL, @"Thumbnail URL must not be nil");

    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:photoModel.thumbnailURL]];

    [NSURLConnection sendAsynchronousRequest:request
                    queue:[NSOperationQueue mainQueue]
                    completionHandler:^(NSURLResponse *response,
                            NSData *data, NSError *connectionError) {
                                photoModel.thumbnailData = data;
                    }
    ];
}

这个方法中没有反应式的痕迹,它只在thumbnail的url处下载数据,在完成回调块中,设置photoModel的属性。
我们差不多介绍完了照片库的基本实现,现在来看看视图控制器,在它的实现文件中,声明如下私有属性。

@interface FRPGalleryViewController ()

@property (nonatomic, strong) NSArray *photosArray;

@end

我们已经实现过初始化方法,现在来看看viewDidLoad方法.

static NSString *CellIdentifier = @"Cell";
- (void)viewDidLoad
{
    [super viewDidLoad];

    // Configure self
    self.title = @"Popular on 500px";

    // Configure view
    [self.collectionView registerClass:[FRPCell class] forCellWithReuseIdentifier:CellIdentifier];

    // Reactive Stuff
    @weakify(self);
    [RACObserve(self, photosArray) subscribeNext:^(id x) {
        @strongify(self);
        [self.collectionView reloadData];
    }];

    // Load data
    [self loadPopularPhotos];
}

我们设置了视图控制的标题,注册了一个类。视图控制器将为显示单元创建或缓存这个类的实例。我们引用了一个不存在的UICollectionViewCell子类,稍后我会介绍它。
这里你会发现一个奇怪的语句。

@weakify(self);
[RACObserve(self, photosArray) subscribeNext:^(id x) {
    @strongify(self);
    [self.collectionView reloadData];
}];

RACObserver是一个C的宏,它输入两个参数:一个对象,和一个到对象的属性。它返回一个信号,当属性的值改变时,这个信号将会发送属性的值。当对象(self)释放的时候,信号会发送完成completion值。当photosArray属性改变的时候,我们的集合视图将会重新加载。
weakify/strongify在ARC的Objective-C下非常常见。Weakify创建了一个新的,弱引用变量,赋值给self。Strongify创建了一个新的,强引用变量在它的作用域中赋值给弱引用的self变量。strongify使用了影子变量,新的,强引用的变量self取代了先前的对self的强引用。
方法subscribeNext:方法块中,在词法作用域中将捕获self,这会引发一个循环引用在self和block块间。block将被subscribeNext:的返回值,RACSubscriber实例强引用,当self释放的时候,RACObserver macro捕获的block将会自动释放。如果没有weakify/strongify,self将永远不会释放。

-(void)loadPopularPhotos {
    [[FRPPhotoImporter importPhotos] subscribeNext:^(id x) {
        self.photosArray = x;
    } error:^(NSError *error) {
        NSLog(@"Couldn't fetch photos from 500px: %@", error);
    }];
}

这个方法负责调用FRPPhotoImporter的importPhotos方法,它订阅了返回的RACReplaySubject来设置私有属性。注意,这在应用程序中引入了一些状态。不幸的是,由于UICollectionViewDataSource协议的原因,这是不可便面的。
我们来看看这些协议方法,有两个必须实现的协议如下.

-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return self.photosArray.count;
}

-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    FRPCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:Cell Identifier forIndexPath:indexPath];
    [cell setPhotoModel:self.photosArray[indexPath.row]];
    return cell;
}

第一个方法只是简单的返回了集合视图的显示单元数量,就是photosArray属性的数组长度。另一个方法从队列中获取一个视图单元,然后在cell上调用setPhotoModel:方法(我们还没实现它,别担心)。如果你对UITableViewDataSource方法熟悉的,这些代码你应该不会陌生。
这就是视图控制器的全部实现。现在来创建UICollectionViewCell的子类FRPCell。修改它的头文件如下.

@class FRPPhotoModel;

@interface FRPCell : UICollectionViewCell

-(void)setPhotoModel:(FRPPhotoModel *)photoModel;

@end

在实现文件中,增加如下的私有接口。

#import "FRPPhotoModel.m"

@interface FRPCell ()

@property (nonatomic, weak) UIImageView *imageView;
@property (nonatomic, strong) RACDisposable *subscription;

@end

这里有两个属性:图像视图和订阅者。图像视图是弱引用,因为它归属于上一级视图管理(这是在写UICollectionViewCell子类时的标准做法)。稍后,我们会实例化并设置图像视图。另一个属性是订阅者subscription,我们将使用了设置图像视图的图像属性。这必须是强引用,而不是弱引用,不然,我们会得到一个运行时错误。
Cell的实现如下.

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (!self) return nil;

    // Configure self
    self.backgroundColor = [UIColor darkGrayColor];

    // Configure subivews
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.bounds];
    imageView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;

    [self.contentView addSubview:imageView];
    self.imageView = imageView;

    return self;
}

以上代码是标准的UICollectionViewCell子类实现样式。创建和赋值了imageView属性。我们必须意识到必须把图像视图保存在一个临时的、强引用的局部变量中以至于它不会立即释放。否则,你会得到一个编译期错误。
在500px图片库中我们还需要实现两个方法,第一个是setPhotoModel:方法。

-(void)setPhotoModel:(FRPPhotoModel *)photoModel {
    self.subscription =
        [[[RACObserve(photoModel, thumbnailData)
            filter:^BOOL(id value) {
                return value != nil;
            }] map:^id(id value) {
                return [UIImage imageWithData:value];
            }] 
         setKeyPath:@keypath(self.imageView, image)
         onObject:self.imageView];
}

这个方法给subscription属性赋值,赋予它setKeyPath:onObject:的返回值。在实际中,该方法不经常使用,我们使用RAC C宏,后面会介绍到。
有两个原因需要订阅者:其一,我们想释放它,使它不接受新值。其二,它的订阅的信号是冷的,所以如果没有人订阅它,它将不会做任何工作。
setKeyPath:onObject:是RACSignal上的方法,它绑定信号最近的值到对象的属性上。在我们的例子中,我们在链式的信号中调用了此方法,我们再仔细看看。

[[RACObserve(photoModel, thumbnailData)
    filter:^BOOL(id value) {
    return value != nil;
}] map:^id(id value) {
    return [UIImage imageWithData:value];
}]

7

信号从宏RACObserve C开始,这个宏只是简单地返回了一个信号,当t对象photoModel上的heumbnaiData值发生变化时,它发送这个值,然后过滤发送的值,去除nil的,最后,映射NSData到UIImage上。
必须意识到NSData到UIImage的映射只能在小图片上应用,如果大图片或者频繁的映射会引起性能问题。幸运的是,我们在内存中缓存了图片的解压而不必每次都重新计算。这超出了本书的范围。
注意到thumbnailData属性不需要我们设置,它在应用程序的其它地方设置,我们的单元图像视图中的图像图像会被更新。
我们破坏了Model-View-Controller了么?是的,也许是,我们将在下一章介绍MVC的缺陷,所以,先别担心这点。
一旦onObject:的参数被释放,订阅者也会自动的释放。我们的单元实例会在集合视图中被复用,所以我们需要释放他们的订阅者,在他们被重用前。我们通过重写UICollectionViewCell的一个方法来达到这个目的。

-(void)prepareForReuse {
    [super prepareForReuse];

    [self.subscription dispose], self.subscription = nil;
}

这个方法中单元被重用前调用。如果我们现在运行我们的应用程序,你将会看到下图的结果。

8

很棒,我们可以通过滑动,来证明我们的订阅者已经手动的释放了。

###Adding to FunctionalReactivePixels
一个简单的视图图片库已经做好,但是如果我们想看看大图片呢?嗯,首先我们先创建一个新的视图控制器,当一个图片单元被按下的时候,新创建的视图控制器会被推入导航栈中。

-(void)collectionView:(UICollectionView*)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { 
    FRPFullSizePhotoViewController *viewController = 
                [[FRPFullSizePhotoViewController alloc] initWithPhotoModels:self.photosArray
                              currentPhotoIndex:indexPath.item];

    viewController.delegate = self;

    [self.navigationController pushViewController:viewController animated:YES];
}

在这个方法中没有什么特别之处,只是平常的Objective-C代码。当了也别完了在视图的实现文件中#import头文件。让我们现在来创建视图控制器。
创建一个UIViewController的子类FRPFullSizePhotoViewController。这不是一个典型的反应式视图控制器。大多数工作都遵守传统,用UIPageViewController作为它的子视图。FRPFullSizePhotoViewController的头文件如下.

@class FRPFullSizePhotoViewController; 

@protocol FRPFullSizePhotoViewControllerDelegate<NSObject>

-(void)userDidScroll:(FRPFullSizePhotoViewController*)viewControllertoPhotoAtIndex:(NSInteger)index;

@end

@interface FRPFullSizePhotoViewController :UIViewController
-(instancetype)initWithPhotoModels:(NSArray*)photoModelArray currentPhotoIndex:(NSInteger)photoIndex;

@property(nonatomic,readonly) NSArray *photoModelArray; 
@property(nonatomic,weak) id<FRPFullSizePhotoViewControllerDelegate> delegate; 

@end

回到图片库的视图控制器,设置好这个类实例的代理。

-(void)userDidScroll:(FRPFullSizePhotoViewController*)viewController toPhotoAtIndex:(NSInteger)index {

    [self.collectionView scrollToItemAtIndexPath:
        [NSIndexPath indexPathForItem:index inSection:0]
        atScrollPosition:UICollectionViewScrollPositionCenteredVertically
    animated:NO];
}

这个方法会更新集合视图的滑动位置,当我们在照片大视图控制器中向新照片滑动的时候。以这种方式,用户按回退按钮就可以看到他刚才看到的图片。
‘#import’必要的模型文件,并且增加两个私有属性。

@interface FRPFullSizePhotoViewController()<UIPageViewControllerDataSource,UIPageViewControllerDelegate>

//Private assignment
@property(nonatomic,strong) NSArray *photoModelArray;

//Private properties
@property(nonatomic,strong) UIPageViewController *pageViewController;

@end

photoModelArray数组是公有的只读属性,但是是一个可读可写的私有属性。第二个是子视图控制器。
我们的初始化代码入下。

-(instancetype)initWithPhotoModels:(NSArray*)photoModelArray currentPhotoIndex:(NSInteger)photoIndex
{
    self = [self init];
    if (!self) return nil;

    // Initialized, read-only properties
    self.photoModelArray = photoModelArray;

    // Configure self
    self.title = [self.photoModelArray[photoIndex] photoName];

    // View controllers
    self.pageViewController = [[UIPageViewController alloc]
                                initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
                                navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
                                options:@{UIPageViewControllerOptionInterPageSpacingKey: @(30)}];
    self.pageViewController.dataSource = self;
    self.pageViewController.delegate = self;
    [self addChildViewController:self.pageViewController];

    [self.pageViewController setViewControllers:@[[self
                photoViewControllerForIndex:photoIndex]]
                direction:UIPageViewControllerNavigationDirectionForward
                animated:NO
                completion:nil];
    return self; 
}

我们设置了属性,以当前照片的名字作为标题,建立pageViewController。我们的viewDidLoad方法非常简单。

-(void)viewDidLoad
{
    [super viewDidLoad]; 

    // Configure self's view
    self.view.backgroundColor = [UIColor blackColor];

    // Configure subviews
    self.pageViewController.view.frame = self.view.bounds;

    [self.view addSubview:self.pageViewController.view];
}

在我的app中,我应该注意到,考虑到简洁的原因,我们没有照片的宽屏显示。这不是一个关于自动改变大小展示样式的一本书, 去看看Erica Sadun的关于自动展示的书
让我们来看看UIPageViewController的数据源和代理协议方法。

-(void)pageViewController:(UIPageViewController*)pageViewController 
    didFinishAnimating:(BOOL)finished 
    previousViewControllers:(NSArray *)previousViewControllers 
    transitionCompleted:(BOOL)completed 
{
    self.title = [[self.pageViewController.viewControllers.firstObject photoModel] photoName];

    [self.delegate userDidScroll:self 
                     toPhotoAtIndex: [self.pageViewController.viewControllers.firstObject photoIndex]];
}


-(UIViewController*)pageViewController:(UIPageViewController*)pageViewController
       viewControllerBeforeViewController:(FRPPhotoViewController *)viewController
   {

        return [self photoViewControllerForIndex:viewController.photoIndex - 1]; 
}

-(UIViewController*)pageViewController:(UIPageViewController*)pageViewController
    viewControllerAfterViewController:(FRPPhotoViewController *)viewController
{
    return [self photoViewControllerForIndex:viewController.photoIndex + 1]; 
}

在这些方法中,没有关于反应式的技术,不过他们确实包含了一定程度的函数式。我赞赏苹果对视图控制器做的抽象。创建视图控制器的方法如下。
-(FRPPhotoViewController*)photoViewControllerForIndex:(NSInteger)index{
if (index >= 0 && index < self.photoModelArray.count) {

        FRPPhotoModel *photoModel = self.photoModelArray[index];

        FRPPhotoViewController *photoViewController = [[FRPPhotoViewController alloc]
                initWithPhotoModel:photoModel index:index]; 

        return photoViewController;
    }

    // Index was out of bounds, return nil
    return nil;
}

它创建和配置了FRPPhotoViewController, 它是UIViewController的子类。这是它的头文件。

@class FRPPhotoModel;

@interface FRPPhotoViewController:UIViewController

-(instancetype)initWithPhotoModel:(FRPPhotoModel*)photoModel index:(NSInteger)photoIndex;

@property(nonatomic,readonly)NSInteger photoIndex;
@property(nonatomic,readonly)FRPPhotoModel *photoModel;

@end

这个视图控制器非常的直接,显示一张照片的全大小。使photo importer下载图像。这非常的简单,我会显示这个实现的整个过程。

//Model
#import "FRPPhotoModel.h"

//Utilities
#import "FRPPhotoImporter.h"
#import <SVProgressHUD.h>

@interface FRPPhotoViewController()

//Private assignment
@property(nonatomic,assign)NSInteger photoIndex;
@property(nonatomic,strong)FRPPhotoModel *photoModel;

//Privateproperties
@property(nonatomic,weak)UIImageView *imageView;

@end

@implementation FRPPhotoViewController

-(instancetype)initWithPhotoModel:(FRPPhotoModel*)photoModel
    index:(NSInteger)photoIndex
{
    self = [self init]; 
    if (!self) return nil;

    self.photoModel = photoModel;
    self.photoIndex = photoIndex;

    return self;
}

-(void)viewDidLoad {
    [super viewDidLoad];

    // Configure self's view
    self.view.backgroundColor = [UIColor blackColor];

    // Configure subviews
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.view.bounds];

    RAC(imageView, image) = [RACObserve(self.photoModel, fullsizedData) 
                map:^id(id value) {
                    return [UIImage imageWithData:value];
                }];

    imageView.contentMode = UIViewContentModeScaleAspectFit;
    [self.view addSubview:imageView];
    self.imageView = imageView;
}

-(void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];

    [SVProgressHUD show];

    // Fetch data
    [[FRPPhotoImporter fetchPhotoDetails:self.photoModel]
            subscribeError:^(NSError *error) {

            [SVProgressHUD showErrorWithStatus:@"Error"];

    } completed:^{
        [SVProgressHUD dismiss];
    }]; 
}

像在我们的集合视图单元中般,我们绑定了UIImageView的image属性到模型数据上。不同的是我们的视图控制器不会被复用,所以我们不需要任何的订阅者释放。当图像视图释放的时候,订阅者会自动释放。
这个实现中另一些有趣的事情是viewWillAppear:方法。

[SVProgressHUDshow];

//Fetchdata
[[FRPPhotoImporter fetchPhotoDetails:self.photoModel] subscribeError:^(NSError *error){
    [SVProgressHUD showErrorWithStatus:@"Error"]; 
}    completed:^{
    [SVProgressHUD dismiss];
}];

我们应该向用户显示这个过程,并取消它当我们收到一个错误或完成值的时候。500px API会返回一个小的照片模型当调用流行的照片API借口时。稍后,我会需要照片的详细信息,所以对每张照片我们会调用另一个API,来检索信息,包裹全副照片大小。

+(NSURLRequest*)photoURLRequest:(FRPPhotoModel*)photoModel{
    return [AppDelegate.apiHelper urlRequestForPhotoID:photoModel.identifier.integerValue];
}

我们没有实现fetchPhotoDetails:方法,所以我们返回FRPPhotoImport类。
在头文件中声明方法并在实现文件中添加如下方法。

+(RACReplaySubject*)fetchPhotoDetails:(FRPPhotoModel*)photoModel{ 
    RACReplaySubject *subject = [RACReplaySubject subject];

    NSURLRequest *request = [self photoURLRequest:photoModel];

    [NSURLConnection sendAsynchronousRequest:request
        queue:[NSOperationQueue mainQueue] 
        completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) { 
            if (data) {
                id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil][@"photo"];

                [self configurePhotoModel:photoModel withDictionary:results];
                [self downloadFullsizedImageForPhotoModel:photoModel];

                   [subject sendNext:photoModel];
                [subject sendCompleted];
            }
            else {
                [subject sendError:connectionError];
            } 
        }];

    return subject; 
}

这个方法遵守了importPhotos方法同样的模式。我们的downloadFullsizedImageForPhotoModel:方法跟downloadThumbnailForPhotoModel:一样。实现如下。

+(void)downloadThumbnailForPhotoModel:(FRPPhotoModel*)photoModel{ 
    [self download:photoModel.thumbnailURL withCompletion:^(NSData *data) {
        photoModel.thumbnailData = data;
    }];
}

+(void)downloadFullsizedImageForPhotoModel:(FRPPhotoModel*)photoModel{ 
    [self download:photoModel.fullsizedURL withCompletion:^(NSData *data) {
        photoModel.fullsizedData = data;
    }];
}

+(void)download:(NSString*)urlString 
    withCompletion:(void(^)(NSData *data))completion {
         NSAssert(urlString, @"URL must not be nil");

        NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString]];
        [NSURLConnection sendAsynchronousRequest:request
                        queue:[NSOperationQueue mainQueue]
                        completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError){
                            if (completion) {
                                completion(data);
                            }
                        }];
}

很棒,我们可以运行app,点击一张照片,将会看到整个大小的照片,我盟能向前向后滑动来看照片,非常好的工作。

9

###Revisiting FunctionalReactivePixels
在上一节中,我们讲了很多ReactiveCocoa的知识点,不过,在代码中我们还有很多机会应用ReactiveCocoa。
第一点,在相册视图控制器中,它实现了三种协议方法,分别是集合视图数据源, 集合视图代理,全尺寸照片视图控制器代理。
我们能抽象任何的代理类型协议方法到一个类的实例中:RACDelegateProxy。
委托代理是一个无类型的对象,你可以调用其rac_signalForSelector:方法来获取信号,当方法选择器触发时,信号会发送新值。
注意:你必须持有委托对象的引用,如果你释放了它,你会得到EXC_BAD_ACCESS异常。在相册视图控制器中增加如下私有属性。

@property(nonatomic,strong) id collectionViewDelegate;

你需要#import进RACDelegateProxy.h文件,因为它不是ReactiveCocoa的核心部分。删除UICollectionViewDelegateProxy 和FRPFullSizePhotoViewControllerDelegate方法,并把下面的代码增加到viewDidLoad方法中。

RACDelegateProxy *viewControllerDelegate = [[RACDelegateProxyalloc] 
                                            initWithProtocol:@protocol(FRPFullSizePhotoViewControllerDelegate)];

[[viewControllerDelegate rac_signalForSelector:@selector(userDidScroll:toPhotoAtIndex:) 
                                  fromProtocol:@protocol(FRPFullSizePhotoViewControllerDelegate)] 
    subscribeNext:^(RACTuple *value) {
        @strongify(self);
        [self.collectionView scrollToItemAtIndexPath:
        [NSIndexPath indexPathForItem:[value.second integerValue] inSection:0]
                     atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:NO];
}];

self.collectionViewDelegate = [[RACDelegateProxyalloc] initWithProtocol:@protocol(UICollectionViewDelegate)];

[[self.collectionViewDelegate rac_signalForSelector:@selector(collectionView:didSelectItemAtIndexPath:)]
    subscribeNext:^(RACTuple *arguments) {
        @strongify(self);
        FRPFullSizePhotoViewController *viewController = [[FRPFullSizePhotoViewControlleralloc]
                initWithPhotoModels:self.photosArray currentPhotoIndex:[(NSIndexPath *)arguments.seconditem]]; 
        viewController.delegate = (id<FRPFullSizePhotoViewControllerDelegate>)viewControllerDelegate; 
        [self.navigationController pushViewController:viewController animated:YES];
}];

我们也需要使用同样的块,在self上调用rac_signalForSelector:方法。然后,在视图控制器的实现文件中也需要提供空的协议方法,以避免编译器的未实现警告。

接着,在此类中,我们还有一个抽象的机会。loadPopularPhotos方法。如果ReactiveCocoa能够为我们管理此方法中的状态,将会非常棒。不用担心,幸运的是,我知道有这么一个方法能做到。

删除该方法,并用如下代码代替对它的调用。

RACSignal *photoSignal = [FRPPhotoImporterimportPhotos]; 
RACSignal *photosLoaded = [photoSignalcatch:^RACSignal*(NSError*error){
    NSLog(@"Couldn't fetch photos from 500px: %@", error);
    return [RACSignal empty]; 
}];

RAC(self,photosArray)=photosLoaded; [photosLoadedsubscribeCompleted:^{
    @strongify(self);
    [self.collectionView reloadData];
}];

第一行是importPhotos调用,我们把它的返回值保存到一个信号中。然后,捕获信号的错误并并做记录。catch:方法不同于subscribeError:方法,它准许信号上非错误值地传递。catch:方法只返回一个空的信号。详细的请查看
这也污染我们的局部变量空间,能够变得更加的简洁,如下

RAC(self,photosArray)=[[[[FRPPhotoImporter importPhotos]
                            doCompleted:^{
                                @strongify(self);
                                [self.collectionView reloadData];
                            }] logError] catchTo:[RACSignal empty]];

使用RAC宏,我们创建了photosLoaded信号到属性photoArray的绑定。
让我们来看看另一个集合视图单元子类的实现。

@interface FRPCell() 

@property(nonatomic,weak) UIImageView *imageView; 
@property(nonatomic,strong) RACDisposable *subscription; 

@end

-(id)initWithFrame:(CGRect)frame 
{
    ... 
}

-(void)prepareForReuse{ 
    [super prepareForReuse];
    [self.subscription dispose], self.subscription = nil;
}

-(void)setPhotoModel:(FRPPhotoModel*)photoModel{

    self.subscription = [[[RACObserve(photoModel, thumbnailData) 
            filter:^BOOL(id value){
                return value != nil;
            }] map:^id(id value) {
                return [UIImage imageWithData:value];
            }] setKeyPath:@keypath(self.imageView, image) onObject:self.imageView];
}

@end

这里有两个标志,预示着我们有机会使用ReactiveCocoa抽象。其一,我们有状态变量subscription属性,其二,我们手动的处理RACDisposable的生命周期。任何时候,只要你在RACDisposable对象上调用dispose,就说明我们有更好的方式来编写成反应式的代码。
我们可以抽象成不使用prepareForReuse方法,而在FRPCell上创建一个属性:photoModel, 把属性放在头文件中,如下:

@property(nonatomic,strong) FRPPhotoModel *photoModel;

接着,彻底的删除setPhotoModel:方法,我们会监视照片模型属性的thumbnailData,在初始化方法中增加如下代码。

RAC(self.imageView,image)=[[RACObserve(self,photoModel.thumbnailData) ignore:nil] map:^(NSData *data) {
                    return [UIImage imageWithData:data];
}];

注意,我们是在self上监视photoModel.thumbnailData的,而不是在self.photoModel上。这不易察觉,但很重要。当self上的photoModel属性变动的时候,或者photoModel的thumbnailData属性改变的时候,在photoModel.thumbnailData上会触发KVO通知。
现在,我们能够彻底的删除subscription属性了。

###Network Layer Revisited
在类FRPPhotoImporter, 网络层中,还可以进一步应用函数反应式编程。让我们来看看图片下载方法。

+(void)downloadThumbnailForPhotoModel:(FRPPhotoModel*)photoModel{
    [self download:photoModel.thumbnailURL withCompletion:^(NSData *data) {
        photoModel.thumbnailData = data;
    }];
}

+(void)downloadFullsizedImageForPhotoModel:(FRPPhotoModel*)photoModel{ 
    [self download:photoModel.fullsizedURL withCompletion:^(NSData *data) {
        photoModel.fullsizedData = data;
    }];
}

+(void)download:(NSString*)urlStringwithCompletion:(void(^)(NSData*data))completion{
    NSAssert(urlString, @"URL must not be nil");
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    [NSURLConnection sendAsynchronousRequest:request
                                       queue:[NSOperationQueue mainQueue]
                           completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) { 
                               if (completion) {
                                    completion(data);
                               }
    }]; 
}

完成块?这又是一个使用信号的机会。并且我们能用NSURLConnection扩展。我们重写方法如下.

+(void)downloadThumbnailForPhotoModel:(FRPPhotoModel*)photoModel{
     RAC(photoModel, thumbnailData) = [self download:photoModel.thumbnailURL];
}

+(void)downloadFullsizedImageForPhotoModel:(FRPPhotoModel*)photoModel{ 
    RAC(photoModel, fullsizedData) = [self download:photoModel.fullsizedURL];
}

+(RACSignal*)download:(NSString*)urlString{ 
    NSAssert(urlString, @"URL must not be nil");
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString]];
    return [[[NSURLConnection rac_sendAsynchronousRequest:request] 
                    map:^id(RACTuple *value) {
                        return [value second];
                    }] deliverOn:[RACScheduler mainThreadScheduler]];
}

这里有两个区别。其一,我们使用RAC宏绑定信号最近的返回的值。其二,返回NSURLConnection 的方法rac_sendAsynchronousRequest的返回值,并进行映射。让我们深入看看。
从文档中可知,rac_sendAsynchronousRequest:返回一个信号,该信号发送网络请求的返回值。RACTuple包含网络请求返回的数据和反应值。当网络错误时,它会输出错误。最后,我们改变了信号的调度者,投递到主线程调度者。调度者跟线程类似。
这样,在后台的调度中,网络信号会返回值。如果我们准许器直接的传送,它可能会冒出到UI,而UI在后台线程中是不可改变的。
让我们返回开头的代码。我们有如下代码。

RAC(photoModel,thumbnailData)=[self download:photoModel.thumbnailURL];

一般,我们不推荐绑定一个对象到信号。然而,我们知道,在网络调用结束的时候,信号也会立即完成,从而终止订阅。只要我们对每个实例每次只绑定一个属性,这就是安全的。
我们仔细检查fetchPhotoDetails:方法,可以再抽象RACReplaySubject的使用。

+(RACReplaySubject*)fetchPhotoDetails:(FRPPhotoModel*)photoModel{
    RACReplaySubject *subject = [RACReplaySubject subject];

     NSURLRequest *request = [self photoURLRequest:photoModel];
        [NSURLConnection sendAsynchronousRequest:request
                                           queue:[NSOperationQueue mainQueue]
                               completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) { 
                                       if (data) {
                                           id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] [@"photo"];

                                           [self configurePhotoModel:photoModel withDictionary:results];
                                        [self downloadFullsizedImageForPhotoModel:photoModel];
                                        [subject sendNext:photoModel];
                                        [subject sendCompleted];
                                    }
                                    else {
                                        [subject sendError:connectionError];
                                    } 
                                }];
    return subject; 
}



+(RACSignal*)fetchPhotoDetails:(FRPPhotoModel*)photoModel{ 
    NSURLRequest *request = [self photoURLRequest:photoModel]; 
    return [[[[[[NSURLConnection rac_sendAsynchronousRequest:request] 
                map:^id(RACTuple *value) {
                        return [value second]; 
                }]
        deliverOn:[RACScheduler mainThreadScheduler]] 
        map:^id(NSData *data) { 

            id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil][@"photo"];
            [self configurePhotoModel:photoModel withDictionary:results];
            [self downloadFullsizedImageForPhotoModel:photoModel];
            return photoModel; 

        }] publish] autoconnect];
}

注意到没,返回值已从RACReplaySubject转变成了RACSignal。

10

我们已经知道deliverOn:怎么工作,让我们把重点放在链尾对信号的操作:publish。publish方法返回RACMulticastConnection, 它会订阅接受到的信号。这正是autoconnect为我们做的, 连接到信号,当它返回订阅的信号返回时。

我们返回的信号是冷的,在订阅的时候,才会去获取数据。因为我们广播了信号,所以网络请求只进行一次,而它的结果是多播的。结果是一个网络信号,当它被订阅的时候,它不会再次操作了,所以它是冷的。
基本上,只要我们能够保证只被订阅了一次,我们就再不需要replay了。
现在我们返回第一个map,它使用了RACTuple,通过reduceEach:, 我们能进行编译期检查。

修改后的代码看如下。

+(RACSignal*)importPhotos{
    NSURLRequest *request = [self popularURLRequest];

    return [[[[[[NSURLConnection rac_sendAsynchronousRequest:request] 
                reduceEach:^id(NSURLResponse *response, NSData *data){
                            return data; 
                }]
                  deliverOn:[RACSchedulermainThreadScheduler]]
                map:^id(NSData*data){
                    id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
                    return [[[results[@"photos"] rac_sequence] 
                            map:^id(NSDictionary *photoDictionary){
                                    FRPPhotoModel *model = [FRPPhotoModel new];
                                    [self configurePhotoModel:model withDictionary:photoDictionary];
                                    [self downloadThumbnailForPhotoModel:model];
                                    return model; 
                            }] array];
                }] publish] autoconnect];
}

###Conclusion
在这章中,我们介绍了ReactiveCocoa应用的很多方面,而以下几点是重点。

  • 函数式编程方法
    正如我们看到的,在数据加载代码中,我们使用了map:, filter:方法。多想想抽象而不是拘泥于具体的实现。
  • 使用subscribeNext:
    subscribeNext:类的方法订阅信号返回RACDisposable实例,直到信号完成,RACDisposale实例才释放。使用这些方法去外面的,非反应式世界交流。
  • 避免显示的状态变量和订阅释放
    作为指导思想,任何时候都要编码显示的订阅者释放。记住我们怎么在FRPCell类中使用takeUntil:方法来自动释放订阅者的。使用takeUtil方法准许信号的值传递直到它的参数发送next值或者completed值。
  • 内存管理
    在ARC下,表面上你不用去管理内存。所以,在ReactiveCocoa中,唯一的注意点是,不要在任何信号块中捕获self。

让我们准备进入下一章把,介绍Model-View-ViewModel模型,增加log功能,并写些单元测试代码。

翻译自:Functional Reactive Programming on iOS – Ash Furrow. 版权所有, 转载请著明出处,保留链接。