ReactiveCocoa教程:下半部【译】

原文:ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2
上半部翻译:ReactiveCocoa教程:上半部【译】

ReactiveCocoa框架让你可以在iOS应用中使用响应式函数编程(FRP)。在教程的上半部分你学会了如何用发送事件流的信号替换标准的动作和事件处理逻辑,还有如何对这些信号进行转换、拆分和重组。

而在教程的下半部分,你将学到ReactiveCocoa更深层次的功能,如:

  • 另外两种事件类型:errorcomplete
  • 限流
  • 多线程
  • 持续化
  • 等等……

事不宜迟,立马开始吧!

推特即时搜索

在本教程中你将要开发的应用叫做推特即时搜索(模仿谷歌即时搜索的概念),一个在输入时即时更新搜索记录的推特搜索应用。

应用的初始项目包含了一些你开始时需要的基础的界面和普通代码。和教程的上半部分一样,你需要使用CocoaPods获取ReactiveCocoa框架并整合到你的项目中。初始项目已经包含了必须的Podfile文件,所以直接打开终端窗口和执行下列命令:

pod install

如果正确执行的话,你会看到相似输出如下:

Analyzing dependencies
Downloading dependencies
Using ReactiveCocoa (2.1.8)
Generating Pods project
Integrating client project

这回生成一个Xcode workspace文件:TwitterInstant.xcworkspace。在Xcode中打开该文件,并确定里面包含了两个项目:

  • TwitterInstant:应用逻辑所在
  • Pods:项目的外部引用,现在包含ReactiveCocoa框架

编译运行,你会看到下图的界面:


先花点时间熟悉一下应用的代码。这是一个非常简单基于拆分视图控制器的app(split view controller-based app)。左边的部分是RWSearchFormViewController,包含了一些通过storyboard添加的UI事件和一个外联的搜索文本框。右边的部分是RWSearchResultsViewController,暂时只是一个UITableViewController的子类。

打开RWSearchFormViewController.m文件你就能看到在viewDidLoad方法中定位了结果展示控制器,并将它指向resultsViewController私有属性。由于这个应用最主要的逻辑就落在RWSearchFormViewController上,这个属性将有助于为RWSearchResultsViewController提供搜索的结果。

校验搜索文本

你首先要做的是验证搜索文本,确保它的长度大于两个字节。如果你完成了上半部教程的话,这对你来说应该是记忆犹新。在RWSearchFormViewController.mviewDidLoad方法下添加以下代码:

- (BOOL)isValidSearchText:(NSString *)text {
  return text.length > 2;
}

这方法简单地判断搜索字符串是否长于两个字节。这逻辑简单得你可能会问:“为什么这都要单独分离出一个方法呢?”

现在的逻辑的确相当的简单,但如果将来这校验需要变得更为复杂呢?在上面的例子中,你只需要在改变一个地方就可以了。不止如此,上面的实现让你的代码可读性更好,指出了你判断字符串长度的原因。想必我们都遵循着良好的编码习惯对么?

在文件的顶部导入ReactiveCocoa:

#import <ReactiveCocoa.h>

在同一文件的viewDidLoad方法末端添加如下代码:

[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    self.searchText.backgroundColor = color;
  }];

不明白这都做了什么?上面的代码实现了三件事:

  • 获取了搜索文本框的文本信号
  • 把文本是否有效的校验结果转换成背景颜色
  • 然后在subscribeNext:的block中将上一步所得赋值给backgroundColor属性

编译运行后能看到当搜索文本过短时,文本框会判断这为无效输入,并把背景颜色变成黄色。

如果用图表描述的话,这个简单的响应式管道看起来是这样的:

每当文本发生改变时,rac_textSignal就会发送包含当前文本内容的next事件。map方法把文本转化成颜色,然后在subscribeNext:环节中获取并赋值给文本框的背景颜色。

想必你还记得上半部中关于这一部分内容对吗?如果不记得,你可能就需要先停下来,去回顾一下上半部的练习部分了。

而在添加推特的搜索逻辑前,这还有一些更加有趣的话题需要提及。

格式化管道

当你研究格式化ReactiveCocoa代码时,惯例是一个操作对应一行,并垂直对齐每一个步骤。

在下图中,你可以看到一个在更为复杂情况下的格式对齐,这是从上一个教程中截取出来的:

这让你更容易看到管道的操作组成。同时这精简了每个block中的代码,任何超过两行的代码都应该封装为一个私有方法。

但很不幸,Xcode并不是太喜欢这种格式化形式,所以你可能需要手动与它的自动缩格逻辑作斗争!

内存管理

考虑一下你添加到TwitterInstantapp的代码,你是否为你刚创建的管道是如何保持(retained)的感到疑惑?当然了,由于管道并没有指向一个变量或者属性,它的引用计数自然不会增加,那它随后是否就会被直接销毁呢?

匿名构造管道是ReactiveCocoa的其中一个设计理念。回顾至今为止你写的所有响应式代码,这应该是显而易见的。

为了支持这种特性,ReactiveCocoa维系保持了它自己的全局信号集(global set of signals)。如果信号有一个或多个订阅者的话,信号就会被激活。如果所有的订阅者都给移除了,该信号就可以被回收。想知道更多关于ReactiveCocoa内管理这个过程的内容,你可以浏览Memory Management文档(译注:文档已失效)。

这就剩下最后一个问题了:怎样取消信号的订阅呢?订阅在接收到completedevent事件后,就会自动移除(你很快就会学到更多关于这部分的内容)。而要手动移除的话可以借助RACDisposable.

RACSignal的订阅方法都返回了一个RACDisposable实例用以在处理方法中手动移除订阅。举一个基于现有管道的简单例子:

RACSignal *backgroundColorSignal =
  [self.searchText.rac_textSignal
    map:^id(NSString *text) {
      return [self isValidSearchText:text] ?
        [UIColor whiteColor] : [UIColor yellowColor];
    }];
 
RACDisposable *subscription =
  [backgroundColorSignal
    subscribeNext:^(UIColor *color) {
      self.searchText.backgroundColor = color;
    }];
 
// at some point in the future ...
[subscription dispose];

可能在实际中你很少会这样做,但知道这么一个可行操作还是很有价值的。

注意:相对应的,如果你创建了一个管道但不曾对其订阅,这管道里的代码,包括像doNext:这样的副作用都永远不会执行。

避免引用循环

ReactiveCocoa已经在背后作了很多精妙的处理,这意味着你并不需要担心太多关于信号内存管理的细节。但这还是有一个重要的内存相关问题你需要关心的。

看看你刚才添加的响应式代码:

[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    self.searchText.backgroundColor = color;
  }];

subscribeNext:的block中使用self以获取文本框的引用。block从会从封闭作用域中捕获并持有了相关值,因而当self和信号中存在强引用时,就会导致引用循环。这会不会导致问题取决于self对象的的生命周期。如果像这个例子一样,它的生命周期贯穿整个应用,就并不构成问题。但这在更加复杂的应用中是很少出现的。

为了避免潜在的引用循环,苹果的官方文档Working With Blocks推荐对self使用弱引用。你可以在现有的代码中作如下实现:

__weak RWSearchFormViewController *bself = self; // Capture the weak reference
 
[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    bself.searchText.backgroundColor = color;
  }];

上面的代码中bself是对self的引用,__weak标记让该引用变为弱引用。注意subscribeNext:的block中现在使用的就是bself变量了,这看起来实在相当不美观!

ReactiveCocoa框架提供了一个可以代替上面代码的小窍门。在文件顶端添加导入如下:

#import "RACEXTScope.h"

然后替换刚才的代码如下:

@weakify(self)
[[self.searchText.rac_textSignal
  map:^id(NSString *text) {
    return [self isValidSearchText:text] ?
      [UIColor whiteColor] : [UIColor yellowColor];
  }]
  subscribeNext:^(UIColor *color) {
    @strongify(self)
    self.searchText.backgroundColor = color;
  }];

代码中的@weakify@strongify是定义在Extended Objective-C库的宏,这也已经包含在ReactiveCocoa框架中。@weakify宏创建了弱应用的影子变量(shadow variables)(如果你需要多个弱引用,你可以传入多个变量),@strongify宏则使用先前传到@weakify的变量创建强引用。

注意:如果你对@weakify@strongify的具体操作感到好奇,你可以在Xcode中选择Product -> Perform Action -> Preprocess “RWSearchForViewController”。这会对视图控制器进行预处理,展开所有的宏让你看到最终的输出。

最后需要注意的是,在block中使用实例变量时也要小心。这也会导致block对self进行强引用。你可以打开编译器警告,当你的代码导致这种问题时去提醒你。在项目的build settings中搜索retain,找到如下的设置:


好了,恭喜你终于熬过了理论知识!现在你已经为最有趣的部分做好了充足的准备:为应用添加真正的功能!

注意:看过上一个教程的敏锐读者想必已经发现在这管道中可以使用RAC宏去替代subscribeNext:。如果你已经发现了,那就改改上面的代码并奖励自己一朵小红花吧!

连接推特

你将要使用Social Framework在你的应用中搜索推特,使用Accounts Framework去获取推特的授权。想要获取更多关于Social Framework的信息,可以查看iOS 6 by Tutorials这篇介绍这个框架的文章。

在添加代码前,你需要在运行本应用的模拟器或iPad上输入你推特的用户密码。打开设置并选中推特选项,在屏幕的右方添加你的用户密码:


初始项目已经添加了所需框架,所以你只需要导入相关头文件。在RWSearchFormViewController.m的顶端添加引用如下:

#import <Accounts/Accounts.h>
#import <Social/Social.h>

在引用的下方添加如下枚举和常量:

typedef NS_ENUM(NSInteger, RWTwitterInstantError) {
    RWTwitterInstantErrorAccessDenied,
    RWTwitterInstantErrorNoTwitterAccounts,
    RWTwitterInstantErrorInvalidResponse
};
 
static NSString * const RWTwitterInstantDomain = @"TwitterInstant";

你很快就会用到这些去区分错误。

在同一个文件,在现有的属性声明下添加如下属性:

@property (strong, nonatomic) ACAccountStore *accountStore;
@property (strong, nonatomic) ACAccountType *twitterAccountType;

ACAccountsStore类为你的设备提供了多种可用的社交媒体账号的连接途径,ACAccountType则类代表了账户的具体类型。

在同一文件的viewDidLoad方法末端添加代码如下:

self.accountStore = [[ACAccountStore alloc] init];
self.twitterAccountType = [self.accountStore 
  accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];

以上代码创建了账号的库(accounts store)和推特的账号标识。

当应用请求连接一个社交账户时,用户会看到一个弹窗。这是一个异步操作,所以最好将它用信号封装起来,以便响应式使用。

继续添加以下代码:

- (RACSignal *)requestAccessToTwitterSignal {
 
  // 1 - define an error
  NSError *accessError = [NSError errorWithDomain:RWTwitterInstantDomain
                                             code:RWTwitterInstantErrorAccessDenied
                                         userInfo:nil];
 
  // 2 - create the signal
  @weakify(self)
  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    // 3 - request access to twitter
    @strongify(self)
    [self.accountStore
       requestAccessToAccountsWithType:self.twitterAccountType
         options:nil
      completion:^(BOOL granted, NSError *error) {
          // 4 - handle the response
          if (!granted) {
            [subscriber sendError:accessError];
          } else {
            [subscriber sendNext:nil];
            [subscriber sendCompleted];
          }
        }];
    return nil;
  }];
}

该方法做了以下操作:

  1. 定义了一个错误,当用户连接遭拒时发送。
  2. 像第一篇文章所说,类方法createSignal返回了一个RACSignal的实例。
  3. 通过账户库链接到推特。此时,用户会看到是否允许app连接到他们推特账号的提示。
  4. 当用户同意或拒绝了连接,信号事件就会发送。如果用户同意连接,一个next事件和紧接一个completed事件就会被发送。如果用户拒绝了连接一个error事件就会被发送。

回想一下上半部的教程,一个信号可以发送三种不同类型的事件:

  • Next
  • Completed
  • Error

在信号的整个生命周期,它可能不发送任何事件,也可能发送一个或多个next事件然后紧跟一个completed事件或者error事件。

最后为了使用这个信号,在viewDidLoad方法末端添加以下代码:

[[self requestAccessToTwitterSignal]
  subscribeNext:^(id x) {
    NSLog(@"Access granted");
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

编译运行,你就能看到下图这样的弹出框:


如果你点击确定按钮,控制台就会答应subscribeNext:block中的记录信息,相反,如果你点击不允许,error里的代码块就会执行并打印响应的记录。

账户管理框架会记住你的选择。所以为了测试两种情况,你需要在菜单中选择重置模拟器: iOS Simulator -> Reset Contents and Settings …。这会有一点繁琐,因为重置后你还需要重新输入你的推特账号密码!

链接信号

当用户成功连接到他们的推特账号(希望如此!),应用就要继续监听搜索输入框的改变来搜索推特。

应用需要等连接推特的信号发送completed事件,并传递给输入框的信号。这种连续的信号链接是相当常见的问题,但是ReactiveCocoa对此有非常优雅的解决方案。

替换viewDidLoad末端现有的管道如下:

[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

then方法会一直等待直到信号的completed事件被发送,然后转订阅参数代码块中返回的信号。这有效的将控制权从一个信号转递给下一个信号。

注意:你已经在上一个管道弱引用过self,所以不再需要在这个管道前添加@weakify(self)了。

编译运行并允许连接,你会看到你在搜索文本框输入的文本此时打印在了控制台:

2014-01-04 08:16:11.444 TwitterInstant[39118:a0b] m
2014-01-04 08:16:12.276 TwitterInstant[39118:a0b] ma
2014-01-04 08:16:12.413 TwitterInstant[39118:a0b] mag
2014-01-04 08:16:12.548 TwitterInstant[39118:a0b] magi
2014-01-04 08:16:12.628 TwitterInstant[39118:a0b] magic
2014-01-04 08:16:13.172 TwitterInstant[39118:a0b] magic!

接下来,为管道添加一个过滤操作,将无效的搜索字符串移除掉。在这个例子中,无效指的就是少于3个字节的字符串:

[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

再次编译运行,实际观察一下过滤效果:

2014-01-04 08:16:12.548 TwitterInstant[39118:a0b] magi
2014-01-04 08:16:12.628 TwitterInstant[39118:a0b] magic
2014-01-04 08:16:13.172 TwitterInstant[39118:a0b] magic!

用图表说明一下现在的应用逻辑,那看起来是这样的:


应用管道从requestAccessToTwitterSignal开始,然后切换到rac_textSignal。与此同时,next事件通过过滤器最终到达订阅的block。第一个环节中发送的error事件同样能够被同一个subscribeNext:error:方法捕获到。

现在你已经有了一个发送搜索文本的信号,是时候使用来搜索推特了!你现在享受到其中的乐趣了么?想必是,毕竟你已经大展一番拳脚了!

推特搜索

Social Framework是使用推特搜索API的一种方式。但是,如你所想,Social Framework并不是响应式的!下一步要做的就是将需要的API方法封装在一个信号中调用。 你这下应该搞明白了吧!
RWSearchFormViewController.m中添加下列方法:

- (SLRequest *)requestforTwitterSearchWithText:(NSString *)text {
  NSURL *url = [NSURL URLWithString:@"https://api.twitter.com/1.1/search/tweets.json"];
  NSDictionary *params = @{@"q" : text};
 
  SLRequest *request =  [SLRequest requestForServiceType:SLServiceTypeTwitter
                                           requestMethod:SLRequestMethodGET
                                                     URL:url
                                              parameters:params];
  return request;
}

这根据v1.1 REST API标准创建了搜索推特的请求。上面的代码使用q搜索参数用以搜索所有包含搜索关键字的推特。你可以在推特的接口文档查看更多关于这个搜索接口信息,以及其他可以传递的有效参数列表。

下一步就是基于这个请求创建信号。在同一文件添加下列方法:

- (RACSignal *)signalForSearchWithText:(NSString *)text {
 
  // 1 - define the errors
  NSError *noAccountsError = [NSError errorWithDomain:RWTwitterInstantDomain
                                                 code:RWTwitterInstantErrorNoTwitterAccounts
                                             userInfo:nil];
 
  NSError *invalidResponseError = [NSError errorWithDomain:RWTwitterInstantDomain
                                                      code:RWTwitterInstantErrorInvalidResponse
                                                  userInfo:nil];
 
  // 2 - create the signal block
  @weakify(self)
  return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    @strongify(self);
 
    // 3 - create the request
    SLRequest *request = [self requestforTwitterSearchWithText:text];
 
    // 4 - supply a twitter account
    NSArray *twitterAccounts = [self.accountStore
      accountsWithAccountType:self.twitterAccountType];
    if (twitterAccounts.count == 0) {
      [subscriber sendError:noAccountsError];
    } else {
      [request setAccount:[twitterAccounts lastObject]];
 
      // 5 - perform the request
      [request performRequestWithHandler: ^(NSData *responseData,
                                          NSHTTPURLResponse *urlResponse, NSError *error) {
        if (urlResponse.statusCode == 200) {
 
          // 6 - on success, parse the response
          NSDictionary *timelineData =
             [NSJSONSerialization JSONObjectWithData:responseData
                                             options:NSJSONReadingAllowFragments
                                               error:nil];
          [subscriber sendNext:timelineData];
          [subscriber sendCompleted];
        }
        else {
          // 7 - send an error on failure
          [subscriber sendError:invalidResponseError];
        }
      }];
    }
 
    return nil;
  }];
}

分解一下步骤:

  1. 刚开始,定义了两种不同的错误,一个表示用户尚未在设备中添加推特账户,另一个表示查询过程中发生的错误。
  2. 像之前一样,创建一个信号。
  3. 使用上一步你创建的方法根据提供的搜索关键字创建请求。
  4. 查询账号库中第一个有效的推特账户。如果没有任何账户返回,发送错误事件。
  5. 执行请求。
  6. 当成功返回时(HTTP返回编码为200),转换返回的JSON数据并伴随next事件发送,紧跟发送一个completed事件。
  7. 当返回状态为不成功时,发送一个error事件。

现在就能使用这个新的信号了!

在本教程的上半部分你学会了如何使用flattenMap去映射每一个next事件为一个全新的信号并接着订阅它。现在就要再次运用这个方法。更新viewDidLoad方法内末端的管道,在最后添加flattenMap环节:

[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

编译运行,在搜索框中输入一些文本。当文本达到或超过3个字节时,你就能在控制台中看到推特的搜索记录。
下面节选了一段你会看到的数据样式:

2014-01-05 07:42:27.697 TwitterInstant[40308:5403] {
    "search_metadata" =     {
        "completed_in" = "0.019";
        count = 15;
        "max_id" = 419735546840117248;
        "max_id_str" = 419735546840117248;
        "next_results" = "?max_id=419734921599787007&q=asd&include_entities=1";
        query = asd;
        "refresh_url" = "?since_id=419735546840117248&q=asd&include_entities=1";
        "since_id" = 0;
        "since_id_str" = 0;
    };
    statuses =     (
                {
            contributors = "<null>";
            coordinates = "<null>";
            "created_at" = "Sun Jan 05 07:42:07 +0000 2014";
            entities =             {
                hashtags = ...

signalForSearchText:方法发送的error事件同样能给subscribeNext:error:接收到。你可能已经记住了这点,但相比你更可能希望亲手试验一下!

在模拟器中打开设置并选中的的推特账户,然后点击删除账户按钮:


重运行应用,应用仍然会获取用户推特账号的授权,尽管已经现在已经没有有效的账号了。因此signalForSearchText方法会发送一个错误,在控制台打印:

2014-01-05 07:52:11.705 TwitterInstant[41374:1403] An error occurred: Error 
  Domain=TwitterInstant Code=1 "The operation couldn’t be completed. (TwitterInstant error 1.)"

Code=1指明了这是一个RWTwitterInstantErrorNoTwitterAccounts错误。在生产环境的应用中,你会希望把错误编码转换成更有意义的形式,而不是只在控制台打印记录。

这引出了一个关于error事件的要点;当信号发出一个错误时,它会直接传递给处理错误的block。这是一个异常的处理流。

提醒:想试验访问推特失败时的异常处理流的话,有一个小窍门,把请求入参改为无效数据就可以了!

多线程

相信你已经迫不及待要将搜索结果的JSON输出转化为UI了,但在那之前你还需要做一件事。而为了明确你要做的事,你还需要做一点探索。

subscribeNext:error:方法中如下的位置添加断点:

重运行应用,如果有必要的话重新输入你的推特账号密码,然后在搜索框中输入一些内容。当执行到断点时你会看到下图相似的景象:


注意调试中的代码并不是在主线程Thread 1中执行的。谨记你只能在主线程中更新UI界面;所以如果你希望在UI界面中更新UI的话,你需要切换执行线程。

这体现了ReactiveCocoa框架一个非常重要的特点。上面的操作是在信号开始发送信号的线程中执行的。在管道的其他环节添加断点�,你可能会惊讶地发现他们并不在同一个线程中执行!

所以要如何更新UI界面呢?传统的做法是使用操作队列(更多的细节可以看本站的另一篇文章 How To Use NSOperations and NSOperationQueues),但是ReactiveCocoa提供了一个更加简便的解决方法。

在管道的flattenMap:方法后添加deliverOn:方法如下:

[[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(id x) {
    NSLog(@"%@", x);
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

现在重运行app,任意输入一点内容让app运行到断点处。你会看到subscribeNext:error:中控制台打印的代码现在在主线程中执行了:


什么?这不过简单的调整了一下代码就能改变事件流的执行线程?这实在太棒了!你可以更新你的UI界面了!

注意:如果你关注一下RACScheduler类你就能看到其提供了相当多的选择来实现不同的线程优先级和管道延迟处理。

现在是时候展现这些推特了!

更新UI界面

打开RWSearchResultsViewController.h你能看到里面已经定义displayTweets:了方法,用以为右手边的视图控制器渲染提供的推特列表。里面的实现非常简单,只是标准的UITableView数据源处理。displayTweets:方法的唯一入参是一个装载RWTweet实例的NSArray。初始项目也已经为你提供了RWTweet对象模型。

subscibeNext:error:中接收的是在signalForSearchWithText:方法中从JSON转换成的NSDictionary类型数据。所以你怎样才能知道字典中的内容呢?

阅读推特的接口文档你能看到接口的响应示例。所得的NSDictionary与这个结构相似,里面有个叫statuses的键对应值为装载推特的NSArray,推特数据也是NSDictionary类型。

RWTweet已经包含一个类方法tweetWithStatus:,用以从给定格式的NSDictionary中提取数据。所以你需要做的只是编写一个循环,并遍历整个数组,为每条推特创建一个RWTweet的实例。

但是,别这样做。之后又更好的解决方法呢。

这篇文章是关于ReactiveCocoa和函数式编程的。数据转换时使用函数式的接口会显得更加干练。你可以使用LinqToObjectiveC来完成这个任务。

关闭项目,并打开你在第一个教程中使用TextEdit创建的Podfile文件(译注:这里作者混乱了,指的是本项目中的Podfile文件,下载时已经提供的,也并不是上一教程里创建的)。更新文件,添加新的依赖:

platform :ios, '7.0'
 
pod 'ReactiveCocoa', '2.1.8'
pod 'LinqToObjectiveC', '2.0.0'

打开终端并跳转到此文件夹,执行以下命令:

pod update

你会看到和以下相似的输出:

Analyzing dependencies
Downloading dependencies
Installing LinqToObjectiveC (2.0.0)
Using ReactiveCocoa (2.1.8)
Generating Pods project
Integrating client project

重新打开workspace文件并确认新的框架已经如下图一样成功引入:


打开RWSearchFormViewController.m并在文件顶端添加引用如下:

#import "RWTweet.h"
#import "NSArray+LinqExtensions.h"

NSArray+LinqExtensions.h头文件是LinqToObjectiveC的一部分,这为NSArray添加了很多方法,用流式接口实现转换,排序,分组和过滤数据。

现在就立即使用这些API……更新在viewDidLoad方法末端的管道如下:

[[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(NSDictionary *jsonSearchResult) {
    NSArray *statuses = jsonSearchResult[@"statuses"];
    NSArray *tweets = [statuses linq_select:^id(id tweet) {
      return [RWTweet tweetWithStatus:tweet];
    }];
    [self.resultsViewController displayTweets:tweets];
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

如你所见,subscribeNext:的block首先获取了推特的NSArraylinq_select方法将装有NSDictionary实例的数组通过提供的block处理转换成新的数组元素,最后返回一个装有RWTweet实例的数组。

转换成功后,相关推特就会被发送到结果视图控制器。最后编译运行,你就能看到推特展示在UI界面中:


注意:ReactiveCocoa和LinqToObjectiveC有着相似的灵感来源。ReactiveCocoa是模仿微软的Reactive Extensions框架,LinqToObjectiveC则是模仿它们的语言集成查询接口(Language Integrated Query APIs),或称作LINQ,特别是用于对象的LINQ

异步加载图片

你可能已经发现在每一条推特的左边有一块间隙。那个位置是用来展示推特用户的头像的。

RWTweet类已经有了一个profileImageUrl属性以记录获取这张图片的URL。为了使列表平滑地滚动,你需要确保从提供的URL中获取图片的代码不在主线程中执行。这可以使用Grand Central Dispatch(GCD)或者NSOperationQueue实现。但是为什么不直接使用ReactiveCocoa呢?

打开RWSearchResultsViewController.m并在文件最后添加如下方法:

-(RACSignal *)signalForLoadingImage:(NSString *)imageUrl {
 
  RACScheduler *scheduler = [RACScheduler
                         schedulerWithPriority:RACSchedulerPriorityBackground];
 
  return [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
    NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:imageUrl]];
    UIImage *image = [UIImage imageWithData:data];
    [subscriber sendNext:image];
    [subscriber sendCompleted];
    return nil;
  }] subscribeOn:scheduler];
 
}

你现在应该相当熟悉这种模式了!

由于你希望这个信号不在主线程中执行,上面的方法先获取了一个后台调度器。然后创建一个信号,该信号在有订阅者时下载图像数据并生成UIImage。最后一步就是使用subscribeOn:,以保证信号在提供的调度器中执行。

搞定!

现在,在同一个文件中更新tableView:cellForRowAtIndex:方法,在方法返回前添加以下代码:

cell.twitterAvatarView.image = nil;
 
[[[self signalForLoadingImage:tweet.profileImageUrl]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(UIImage *image) {
   cell.twitterAvatarView.image = image;
  }];

在上面的代码中,由于这些单元(cell)会重复使用并可能包含先前遗留的数据,因而首先重置了图片。然后创建用以获取图片数据的信号。而接下来你先前遇到过的deliverOn:方法将next事件调整到了主线程上,以便安全地执行subscribeNext:中的block。

多么简单有效!

编译运行后就能看到头像现在都已经正确显示了:


节流

你可能已经发现,每当你输入一个新的字符,就会立马执行一次新的推特搜索。如果你是一个熟练的打字员(或者只是按紧删格键),这会导致应用在一秒内作出多个搜索请求。这种实现并不理想,原因有二:第一,这在对推特的搜索接口造成冲击的同时舍弃了大部分返回的结果;第二,不断的更新结果会扰乱用户的注意力!

更好的实现应该是当搜索文本在一个短时间内,比如说500毫秒,没有改变的话再执行搜索。正如你可能猜到的那样,ReactiveCocoa还是很容易就能实现这一点!

打开RWSearchFormViewController.m,更新viewDidLoad末端的管道,在过滤后新增节流操作:

[[[[[[[self requestAccessToTwitterSignal]
  then:^RACSignal *{
    @strongify(self)
    return self.searchText.rac_textSignal;
  }]
  filter:^BOOL(NSString *text) {
    @strongify(self)
    return [self isValidSearchText:text];
  }]
  throttle:0.5]
  flattenMap:^RACStream *(NSString *text) {
    @strongify(self)
    return [self signalForSearchWithText:text];
  }]
  deliverOn:[RACScheduler mainThreadScheduler]]
  subscribeNext:^(NSDictionary *jsonSearchResult) {
    NSArray *statuses = jsonSearchResult[@"statuses"];
    NSArray *tweets = [statuses linq_select:^id(id tweet) {
      return [RWTweet tweetWithStatus:tweet];
    }];
    [self.resultsViewController displayTweets:tweets];
  } error:^(NSError *error) {
    NSLog(@"An error occurred: %@", error);
  }];

throttle操作只有在时间间隔内没有接收到新的next事件时才会发送next事件给下一环节。这是不是相当简单!

编译运行,这时搜索结果只在停止输入超过500毫秒时才会更新。这感觉好多了对吗?你的用户也会这么想的。

并且……随着最后一步的完成,你的推特即时搜索应用已经完成了。给自己一点掌声并跳支舞放松一下吧!

如果你在教程的过程中感到迷惑的话,你可以下载浏览最终的项目(当然别忘了在打开前在项目所在目录运行pod instal命令),你也可以在GitHub找到这个项目,那里有对应教程中每一步操作的提交记录。

总结

在结束教程并给自己泡上一杯咖啡庆祝之前,非常值得欣赏一下项目最终搭建的管道。

这是一个相当复杂的数据流,但所有都简明的表达在了一个响应式管道中。这是多么迷人的景象啊!你可以想象如果使用非响应式技术的话来时实现这些功能的话,应用该变得多么复杂吗?而且要理清楚数据的流向将变得多么困难?听着就觉得够麻烦的了,而你现在已经不再需要重蹈覆辙了!

现在你体会ReactiveCocoa是多么了不起了吧!

最后一点,ReactiveCocoa让使用又称为MVVM的Model View ViewModel设计模式变为可能,其更有效的分离了应用逻辑和视图逻辑。如果有人对后续关于用ReactiveCocoa实现MVVM的文章感兴趣的话,请在评论中告诉我。我非常希望听到能够你的想法和经验!(译注:后续也有作者关于MVVM的教程,有时间会继续进行翻译!)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容