ReactiveCocoa的实践 - FunctionalReactivePixels的基础知识

优质
小牛编辑
124浏览
2023-12-01

FunctionReactivePixels将会是一个简单的观看’500px’中最受欢迎的照片的应用。一旦我们完成这一节,应用的主界面将会像下面这样:

app_main_page

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

app_secondary_detailpage

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

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

app_project_create

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

  1. #import the new flow layout's header in the view controller's implementation file and
  2. #then override FRPGalleryViewController's init method
  3. - (id)init{
  4. FRPGalleryFlowLayout *flowLayout = [[FRPGalleryFlowLayout alloc] init];
  5. self = [self initWithCollectionViewLayout:flowLayout];
  6. if(!self) return nil;
  7. return self;
  8. }

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

  1. @implementation FRPGalleryFlowLayout
  2. - (instancetype)init{
  3. if (!(self = [super init])) return nil;
  4. self.itemSize = CGSizeMake(145,145);
  5. self.minimumInteritemSpacing = 10;
  6. self.minimumLineSpacing = 10;
  7. self.sectionInset = UIEdgeInsetsMake(10,10,10,10);
  8. return self;
  9. }
  10. @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:didFinishLaunchingWithOptions:方法中实例化这个变量。

  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.现在让我们继续往下看:

  1. + (RACSignal *)importPhotos{
  2. RACReplaySubject * subject = [RACReplaySubject subject];
  3. NSURLRequest * request = [self popularURLRequest];
  4. [NSURLConnection sendAsynchronousRequest:request
  5. queue:[NSOperationQueue mainQueue]
  6. completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError){
  7. if (data) {
  8. id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
  9. [subject sendNext:[[[results[@"photos"] rac_sequence] map:^id(NSDictionary *photoDictionary){
  10. FRPPhotoModel * model = [FRPPhotoModel new];
  11. [self configurePhotoModel:model withDictionary:photoDictionary];
  12. [self downloadThumbnailForPhotoModel:model];
  13. return model;
  14. }] array]];
  15. [subject sendCompleted];
  16. }
  17. else{
  18. [subject sendError:connectionError];
  19. }
  20. }];
  21. return subject;
  22. }

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

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

这个直接返回的结果值得我们关注。

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

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

  1. 创建一个RACSubject.
  2. 从异步调用的完成block中向RACSubject传送结果值。
  3. 立即返回这个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. if(data){
  2. id results = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
  3. [subject sendNext:[[[results[@"photos"] rac_sequence] map:^id (NSDictionary *photoDictionary){
  4. FRPPhotoModel *model = [FRPPhotoModel new];
  5. [self donwloadThumbnailForPhotoModel:model];
  6. return model;
  7. }] array]];
  8. [subject sendCompleted];
  9. }
  10. else{
  11. [subject sendError:connectionError];
  12. }

测试是否有数据返回时,可以说这不是一个很好的错误条件检测的方法,但这是一个教学的例子。如果数据为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:withDictionary:方法,看起来应该像下面这样:

  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. photoModel.thumbnailData = data;
  8. }];
  9. }

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

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

  1. @interface FRPGalleryViewController ()
  2. @property (nonatomic , strong) NSArray *photoArray;
  3. @end

来看下viewDidLoad中的实现。

  1. static NSString * CellIdentifier = @"Cell";
  2. - (void)viewDidLoad{
  3. [super ViewDidLoad];
  4. //Configure self
  5. self.title = @"Popular on 500px";
  6. //Configure View
  7. [self.collectionView registerClass:[FRPCell class] forCellWithReuseIdentifier:CellIdentifier];
  8. //Reactive Stuff
  9. @weakify(self);
  10. [RACObserver(self, photosArray) subscribeNext:^(id x){
  11. @strongify(self);
  12. [self.collectionView reloadData];
  13. }];
  14. //Load data
  15. [self loadPopularPhotos];
  16. }

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

在’Reactive Stuff’注释之下,你会发现一些奇怪的语法。

  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条件下@weakify/@strongify这个双人舞是非常常见的。@weakify创建一个新的self的弱引用weakself,@strongify创建这个weakself的强引用,并在@strongify的作用域中起作用。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:OnObject:的返回值赋给了self.subscription.实践中这种方法根本不使用,我们使用RAC的C语法宏来代替,不久之后我们就会涉及这方面的知识。

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

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

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

  1. [[RACObserver (photoModel, thumbnailData)
  2. filter:^BOOL (id value){
  3. return value != nil;
  4. }] map:^ id (id value){
  5. return [UIImage imageWithData:value];
  6. }];

chained_signal

信号由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];
  3. [self.subscription dispose], self.subscription = nil;
  4. }

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

disposing_subscription_works

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