FunctionalReactivePixels的基础知识

    当然我们也可以像下图一样观看全屏模式下的图片。

    app_secondary_detailpage

    这个App将使用Collection Views。如果你没有太多这方面的经验,也不需要太过担心—-他们(CollectionView)就像TableView一样,使用起来非常简单。如果你对UICollectionView感兴趣,可以阅读我的另一本书.

    我们将使用CocoaPods来管理我们的依赖,现在创建一个新的工程。我喜欢使用空模版以便我可以完全控制viewController层级。

    首先、我们将创建一个UICollectionViewController的子类FRPGalleryViewController.同时我们创建一个UICollectionViewFlowLayout的子类FRPGalleryFlowLayout.

    这将初始化collection View的layout为我们自己的layout.这个flowlayout子类的实现非常简单,只需要设置一些属性就可以了。

    1. - (instancetype)init{
    2. if (!(self = [super init])) return nil;
    3. self.itemSize = CGSizeMake(145,145);
    4. self.minimumInteritemSpacing = 10;
    5. self.minimumLineSpacing = 10;
    6. self.sectionInset = UIEdgeInsetsMake(10,10,10,10);
    7. return self;
    8. }
    9. @end

    很棒!下一步,我们需要把Viewcontroller展现在屏幕上。为了实现这个,我们首先要在应用的application delegate的application: didFinishLaunchingWithOptions:方法。我们想要将collectionview Controller置于一个navigationController容器中:

    1. - (BOOL)application:(UIApplication *)application
    2. didFinishLaunchingWithOptions:(NSDictionary *)launchOptions{
    3. self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    4. self.window.rootViewController = [[UINavigationController alloc] initWithRootViewController:[[FRPGalleryViewController alloc] init]];
    5. self.window.backgroundColor = [UIColor whiteColor];
    6. [self.window makeKeyAndVisible];
    7. return YES;
    8. }

    很好!如果我们现在运行,我们将看到一个空视图。

    app_main_emptypage

    我们来填充一些内容。创建一个Podfile文件,并填写如下内容:

    1. platform :ios, "7.0"
    2. target "FRP" do
    3. pod 'ReactiveCocoa', '~> 2.1.4'
    4. pod 'libextobjc', '~> 0.3'
    5. pod '500-iOS-api', '~> 1.0.4'
    6. pod 'SVProgressHUD', '~> 0.9'
    7. end
    8. target "FRPTests" do
    9. end

    下一章,我们将添加一些测试。现在运行pod install,然后打开Xcode通用的workspace文件。打开与编译头文件FRP-Prefix.pch(Xcode6之后,新建工程默认不加载pch文件,需要自己添加,Apple的最佳实践中已经不推荐使用全局的预编译pch文件),然后添加下面的内容。这些语义会自动加载到项目的所有文件中。

    1. //Pods
    2. #import <ReactiveCocoa/ReactiveCocoa.h>
    3. #import <500px-iOS-api/PXAPI.h>
    4. #import <libextobjc/EXTScope.h>
    5. //App Delegate
    6. #import "FRPAppDelegate.h"
    7. #define AppDelegate ((FRPAppDelegate *)[[UIApplication sharedApplication] delegate])

    对于这样使用AppDelegate单例的用法,Saul Mora说:“每次看到你这么做,我家的狗都想死”。
    但是这不是一本关于设计模式的书—-这是一本关于ReactiveCocoa的书,所以我们可能要害死一些狗狗。。。

    创建一个AppDelegate的属性来hold住500px API客户端

    1. @property (nonatomic, readonly) PXAPIHelper * apiHelper;

    application方法中实例化这个变量。

    1. self.apiHelper = [[PXAPIHelper alloc]
    2. initWithHost:nil
    3. consumerKey:@"DC2To2BS0ic1ChKDK15d44M42YHf9gbUJgdFoF0m"
    4. consumerSecret:@"i8WL4chWoZ4kw9fh3jzHK7XzTer1y5tUNvsTFNnB"];

    我提供了一对一次性消费的密钥—-请不要疯到你也使用这对密钥,你可以自己的。

    好了,我们差不多也该建立数据的加载了。我们需要一个数据模型来hold住我们的信息。我创建了下面的FRPPhotoModel

    1. @interface FRPPhotoModel : NSObject
    2. @property (nonatomic, strong) NSString *photoName;
    3. @property (nonatomic, Strong) NSNumber *identifier;
    4. @property (nonatomic, strong) NSString *photographerName;
    5. @property (nonatomic, strong) NSNumber *rating;
    6. @property (nonatomic, strong) NSString *thumbnailURL;
    7. @property (nonatomic, strong) NSData *thumbnailData;
    8. @property (nonatomic, strong) NSString *fullsizedURL;
    9. @property (nonatomic, strong) NSData * fullsizedData;
    10. @end
    11. @implementation FRPPhotoModel
    12. @end

    非常好,到这里,我们将不直接在ViewController中加载内容,相反,这部分逻辑将被抽象到另一个类中。创建一个名为FRPPhotoImporter的类。

    到现在为止没有一处代码是关于函数式的。别担心,我们就要这么做了!这个FRPPhotoImporter将不会真正返回一个FRPPhotoModel对象,相反他会返回一些随身携带API最新的请求结果的信号。

    1. @interface FRPPhotoImporter : NSObject
    2. + (RACSignal *)importPhotos;
    3. @end

    FRPPhotoImporterimportPhotos方法返回一个从API发送最新结果的RACSignal。这个RACSignal实际上是一个RACReplaySubject.但是由于ReactiveCocoa编程指南中不建议使用RACSubjects,我们申明的公共接口的返回类型为RACSignal而非RACSubject.现在让我们继续往下看:

    这里面包含的内容太多,我们慢慢来整理一下:

    • 首先我们创建了一个新的RACReplaySubject实例(这将是我们要返回的对象)。
    • 其次我们创建了一个NSURLRequest来获取500px上热门的FRPPhotoModel数据。
    • 随后我们发送一个网络的异步请求,并立即返回RACSubject对象。

    这个RACSubject对象被异步网络请求的回调block捕获,当API接口返回数据时回调block就会被调用,然后RACSubject对象会将结果传送出来,这些值将被我们的订阅了RACSubject信号的接收者所接受。

    这是你看到的异步操作中,一个非常普通的模式。

    1. 从异步调用的完成block中向RACSubject传送结果值。
    2. 立即返回这个RACSubject对象

    重要的是,要注意一个普通的RASSubject及其子类RACReplaySubject之间的区别。RACReplaySubject可以确保他背后的Subject只会被订阅一次,避免执行重复的操作(就像上面这种网络活动的情况),RACReplaySubject将会缓存这个订阅的值,并将其转发给新的订阅者们—- 对我们的需求来说这非常完美。就像ReactiveCocoa的开发者Justin Spahr-Summers所指出的,这也能够避免可能的竞争状况。

    我们发送了一个完整的数据集而不是单个随时间变化的流。如果我们连环地发送一个个单独的FRPPhotoModel流,这将’更加Reactive’,也有助于实现分页的需求,但是我们不打算采用这种方式,因为他有点点‘高级’了。你可以下载octokit:一个类似这种方式的例子。

    URL请求的构造方法看起来应该是这样的:

    1. + (NSURLRequest *)popularURLRequest {
    2. return [AppDelegate.apiHelper urlRequestForPhotoFeature:PXAPIHelperPhotoFeaturePopular
    3. resultsPerPage:100 page:0
    4. photoSize:PXPhotoModelSizeThumbnail
    5. sortOrder:PXAPIHelperSortOrderRating
    6. except:PXPhotoModelCategoryNude];
    7. }

    subject发送什么,完全看不到好吗?呃。这取决于回调block.

    1. id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
    2. [subject sendNext:[[[results[@"photos"] rac_sequence] map:^id (NSDictionary *photoDictionary){
    3. FRPPhotoModel *model = [FRPPhotoModel new];
    4. [self donwloadThumbnailForPhotoModel:model];
    5. return model;
    6. }] array]];
    7. [subject sendCompleted];
    8. }
    9. else{
    10. [subject sendError:connectionError];
    11. }

    测试是否有数据返回时,可以说这不是一个很好的错误条件检测的方法,但这是一个教学的例子。如果数据为nil,我们会发送一个errorValue,否则我们会反序列化JSON数据并处理它。这不太容易很快就看清楚是怎么做到的,让我们来仔细看看。

    1. [subject sendNext:[[[results[@"photos"] rac_sequence] map:^id (NSDictionary *photoDictionary){
    2. FRPPhotoModel * model = [FRPPhotoModel new];
    3. [self configurePhotoModel:model withDictionary:photoDictionary];
    4. [self downloadThumbnailForPhotoModel:model];
    5. return model;
    6. }] array]];
    7. [subject sendCompleted];

    发送一个值,随着subject撸过去,第一个表达式结构相当简洁(但是场景很典型)。这个值是photos的值,然后转化为一个序列(sequence),然后做映射,最后转化为一个数组。这是上一章介绍的非常简单的map技术。

    这个map(映射)非常有意思。序列中的每一个元素,都会创建一个新的FRPPhotoModel对象、设置它然后返回它。为每一个results[ @"photos" ]的数组元素创建了一个FRPPhotoModel数组。这个数组就是随着subject发送过来的值。最后我们发送一个完成值completedValue好让订阅者们知道任务完成了。

    value_photoModel_map

    注意在信号上手动附送值的能力是非典型的,这是RACSubject实例的专属能力。

    configurePhotoModel方法,看起来应该像下面这样:

    1. + (void)configurePhotoModel:(FRPPhotoModel *)photomodel withDictionary:(NSDictionary *)dictionary{
    2. //Basic details fetched with the first, basic request
    3. photomodel.photoname = dictionary[@"name"];
    4. photomodel.identifier = dictionary[@"id"];
    5. photomodel.photographerName = dictionary[@"user"][@"username"];
    6. photomodel.rating = dictionary[@"rating"];
    7. photomodel.thumbnailURL = [self urlForImageSize:3 inArray:dictionary[@"images"]];
    8. //Extended attributes fetched with subsequent request
    9. if (dictionary[@"comments_count"]){
    10. photomodel.fullsizedURL = [self urlForImageSize:4 inArray:dictionary[@"images"]];
    11. }
    12. }

    除了URL的属性设置,都是最基本的东西。依靠其他的方法来从500px的API中返回的图片列表中提取正确的url信息。500px API返回的数据结构是下面这样的格式:

    1. (
    2. {
    3. size = size;
    4. url = ...;
    5. }
    6. )

    这是一个字典数组,每一个字典中包含一个size字段和一个url字段。我们读取这样字段的方法如下:

    1. + (NSString *)urlForImageSize:(NSInteger)size inDictionary:(NSArray *)array{
    2. return [[[[[array rac_sequence] filter:^ BOOL (NSDictionary * value){
    3. return [value[@"size"] integerValue] == size;
    4. }] map:^id (id value){
    5. return value[@"url"];
    6. }] array] firstObject];
    7. }

    这里有一些隐含的错误处理,如果序列为空,NSArrayfirstObject方法默认返回nil.

    • 第一步,我们过滤掉那些size字段不匹配要求的字典。
    • 然后,将这些符合要求的字典做一次映射来提取字典中url字段的内容。
    • 最后,我们获得一个NSString 对象的序列,把它转化为数组,然后返回firstObject.

    error_handling

    在ReactiveCocoa中类似上面的链式调用非常常见。值从rac_sequence推送到filter:方法中,最后推送到map:方法里。最后调用序列rac_sequencearray方法,将序列的结果转化为array.

    最后,我们的downloadThumbnailForPhotoModel:方法,看起来应该是下面这样:

    1. + (void)downloadThumbnailForPhotoModel:(FRPPhotoModel *)photoModel{
    2. NSAssert(photoModel.thumbnailURL, @"Thumbnail URL must not be nil");
    3. NSURLRequest * request = [NSURLRequest requestWithURL:[NSURL URLWithString:photoModel.ThumbnailURL]];
    4. [NSURLConnection sendAsynchronousRequest:request
    5. queue:[NSOperationQueue mainQueue]
    6. completionHandler:^(NSURLResponse *response, NSData *data, NSError * connectionError){
    7. }];
    8. }

    这个方法里面没有任何的关于Reactive的部分—-仅仅是下载thumbnail的url,然后在完成块中适当地设置相关属性。

    我们几乎做完了这个画廊所需要的所有基础的事情,接下来,我们看看viewController.在实现文件里定义下面的的私有属性。

    1. @interface FRPGalleryViewController ()
    2. @end

    来看下viewDidLoad中的实现。

    我们为viewController设置了一个title并且为collectionView注册了一个类,collectionView将会在他的cells中复用这个类的实例。这里我引用了一个不存在的UICollectionViewCell的子类,我们很快会创建她。

    1. @weakify(self);
    2. [RACObserver(self, photosArray) subscribeNext:^(id x){
    3. @strongify(self);
    4. [self.collectionView reloadData];
    5. }];

    RACObserver是一个C的宏定义,带两个参数:对象及对象某个属性的keyPath(关键路径)。他会返回一个带属性值的信号,无论这个属性的值怎么变都会及时地通过该信号反馈出来。在这里当self结束分配的时候会发送一个completion Value的值。订阅这个信号的目的是无论我们的photosArray中的元素属性怎么变,我们都能够在collectionView重新加载的时候实时获取反馈。

    在Objective-C的ARC条件下/@strongify这个双人舞是非常常见的。创建一个新的self的弱引用weakself,@strongify创建这个weakself的强引用,并在的作用域中起作用。strongify的这种做法,一般称为“影子变量”,那是因为这个新的强引用的变量就叫self,替代了原本强引用的self.

    一般而言,subscribeNext:的block将捕获其词法范围内的self,造成self和block之间的循环引用。block被subscribeNext:的返回值,一个RACSubscriber实例,强引用,然后被RACObserver宏捕获。解除分配时,RACOberver会自动解除第一个参数的分配,这样的话self就应该被解除分配,但self被block强引用,self要得以解除分配的唯一条件即引用计数为0,这样的话就必须先解除block的分配,而前面的分析我们知道block被RACSubscriber实例引用,而该实例默认被self强引用,因此,如果不调用weakify/strongify,self就永远也不可能解除分配。

    最后,我们实际来调用loadPopularPhotos(他的实现如下)

    1. - (void)loadPopularPhotos{
    2. [[FRPPhotoImporter importPhotos] subscribeNext:^(id x){
    3. self.photosArray = x;
    4. } error:^(NSError * error){
    5. NSLog(@"Couldn't fetch photofrom 500px: %@",error);
    6. }];
    7. }

    这个方法实际上负责调用FRPPhotoImporterimportPhotos方法(现在请加上他的头文件),他订阅了我们私有成员属性的结果。由于UICollectionViewDataSource协议的架构,我们不得不把这些状态引入进来。

    现在让我们来看一下这些协议方法,有两个是必须的,实现如下:

    1. - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section{
    2. return self.photosArray.count;
    3. }
    4. - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath{
    5. FRPCell * cell = [collectionView dequeueReusableCellWithReuseIdentifier:CellIdentifier forIndexPath:indexPath];
    6. [cell setPhotoModel:self.photosArray[indexPath.row]];
    7. return cell;
    8. }

    第一个方法简单地返回了collectionView中的cell的数量,在这里,准确地讲是photosArray属性的cell数量。接下来的这个方法从collectionView列表中获得了一个cell实例,并调用其上的setPhotoModel:方法(这个我们还没有实现,但别担心)。这些代码应该看起来非常熟悉,如果你曾经处理过UITableViewDataSource的方法的话。

    这就是我们ViewController完整的实现。现在我们来创建UICollectionViewCell的子类,命名为FRPCell,像下面这样来修改他的头文件。

    1. @class FRPPhotoModel;
    2. @interface FRPCell : UICollectionViewCell
    3. - (void)setPhotoModel:(FRPPhotoModel *)photoModel;
    4. @end

    在实现文件中添加下面的私有扩展:

    1. #import "FRPPhotoModel.h"
    2. @interface FRPCell ()
    3. @property (nonatomic , weak ) UIImageView * imageView;
    4. @property (nonatomic , strong ) RACDisposeable *subscription;
    5. @end

    这里有两个属性:一个图片视图和一个订阅者。图片视图是弱引用,因为它属于父视图(这是UICollectionViewCell的一个标准的用法),我们将实例化并赋值给imageView。接下来的属性是一个订阅,当使用ReactiveCocoa来设置图像视图的图像属性时,我们将接触到它。注意它必须是强引用而非弱引用否则你会得到一个运行时的异常。

    1. - (id)initWithFrame:(CGRect)frame{
    2. self = [super initWithFrame:frame];
    3. if(!self) return nil;
    4. //Configure self
    5. self.backgroundColor = []UIColor darkGrayColor];
    6. //Configure subviews
    7. UIImageView * imageView = [[UIImageView alloc] initWithFrame:self.bounds];
    8. imageView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
    9. [self.contentView addsubView:imageView];
    10. self.imageView = imageView;
    11. return self;
    12. }

    标准的UICollectionView子类的模版会创建并分配imageView属性。注意,我们必须有一个(被self)强引用的本地变量作为中介来存储imageView,这样就不会在赋值给self的imageView属性的时候,imageView被立即解除分配。否则会有编译错误。

    完成我们的500px画廊,我们还需要实现两个方法,第一个就是setPhotoModel:方法

    1. - (void)setPhotoModel:(FRPPhotoModel *)photoModel{
    2. self.subscription = [[[RACObserver(photoModel, thumbnailData)
    3. filter:^ BOOL (id value){
    4. return value != nil;
    5. }] map:^id (id value){
    6. return [UIImage imageWithData:value];
    7. }] setKeyPath:@keypath(self.imageView, image) onObject:self.imageView];
    8. }

    这种方法来给订阅的属性赋值,我们老早就知道了。它把setKeyPath的返回值赋给了self.subscription.实践中这种方法根本不使用,我们使用RAC的C语法宏来代替,不久之后我们就会涉及这方面的知识。

    两个原因导致订阅是必要的:

    1. 1. 当它没有接受一个新的值时,我们想延迟处理。
    2. 2. 信号的订阅通常是冷信号,除非有人订阅他(信号),否则信号不会起作用。

    setKeyPath:onObject:RACSignal的一个方法:绑定最新的信号的值给对象的关键路径。在这里我们在一个级联的信号上调用了这个方法,让我们来仔细看看:

    信号由RACObserver这个C的宏生成,这个宏简单地返回一个监控目标对象关键路径值变化的信号。在我们这个例子中,我们的目标对象是photoModel,关键路径为thumbnailData属性。我们过滤掉所有的nil值,然后对过滤后的值做映射:把NSData实例转为UIImage对象。

    注意,把NSData实例转化为UIImage的这个映射仅在小图上可以很好地运行,如果频繁地做这个映射或者作用到大图上会引起性能问题。理想的情况下,我们会缓存这些已经解压的图像以避免每一次都重复计算。这个技术不是本书所讨论的范畴,但我们将使用另一个通过ReactiveCocoa来实现的方法。

    thumbnailData属性根本不需要在这里设置,他可以在稍后的某个时间在应用的其他部分来完成设置,然后cell的图像就会像魔术一般更新。

    可以让我们稍微突破一下Model-View-Controller模式好吗?只是一点点的不守规矩。幸运的是,下一章我们将看到无处不在的MVC模式的困境,所以我们不必担心这一点点的突破,一点点的改进。

    上面提到的setKeyPath:onObject:方法中,一旦onObject:对象被释放,他的订阅也会被自动取消。我们的cell实例是被collectionView所复用的,因此在复用的时候,我们需要取消cell上各组件的订阅。我们可以通过重写UICollectionViewCell的下列方法达成:

    1. - (void)perpareForReuse {
    2. [super prepareForReuse];

    这个方法在Cell被复用之前调用。如果现在运行我的应用,我们可以看到下面的结果:

    太好了!我们可以通过滚动视图来证实我们手动处理订阅的有效性。