”
转自无网不剩的博客
概述
为什么要使用RAC?
一个怪怪的东西,从Demo看也没有让代码变得更好、更短,相反还造成理解上的困难,真的有必要去学它么?相信这是大多数人在接触RAC时的想法。RAC不是单一功能的模块,它是一个Framework,提供了一整套解决方案。其核心思想是「响应数据的变化」,在这个基础上有了Signal的概念,进而可以帮助减少状态变量(可以参考jspahrsummers的
PPT),使用MVVM架构,统一的异步编程模型等等。
为什么RAC更加适合编写Cocoa App?说这个之前,我们先来看下Web前端编程,因为有些相似之处。目前很火的AngularJS有一个很重要的特性:数据与视图绑定。就是当数据变化时,视图不需要额外的处理,便可正确地呈现最新的数据。而这也是RAC的亮点之一。RAC与Cocoa的编程模式,有点像AngularJS和jQuery。所以要了解RAC,需要先在观念上做调整。
以下面这个Cell为例
正常的写法可能是这样,很直观。
- -(void)configureWithItem:(HBItem*)item
- {
- self.username.text=item.text;
- [self.avatarImageViewsetImageWithURL:item.avatarURL];
- //其他的一些设置
- }
但如果用RAC,可能就是这样
Signal
Signal是RAC的核心,为了帮助理解,画了这张简化图
这里的数据源和sendXXX,可以理解为函数的参数和返回值。当Signal处理完数据后,可以向下一个Signal或Subscriber传送数据。可以看到上半部分的两个Signal是冷的(cold),相当于实现了某个函数,但该函数没有被调用。同时也说明了Signal可以被组合使用,比如RACSignal *signalB = [signalA map:^id(id x){return x}],或RACSignal *signalB = [signalA take:1]等等。
当signal被subscribe时,就会处于热(hot)的状态,也就是该函数会被执行。比如上面的第二张图,首先signalA可能发了一个网络请求,拿到结果后,把数据通过sendNext方法传递到下一个signal,signalB可以根据需要做进一步处理,比如转换成相应的Model,转换完后再sendNext到subscriber,subscriber拿到数据后,再改变viewmodel,同时因为View已经绑定了viewmodel,所以拿到的数据会自动在View里呈现。
还有,一个signal可以被多个subscriber订阅,这里怕显得太乱就没有画出来,但每次被新的subscriber订阅时,都会导致数据源的处理逻辑被触发一次,这很有可能导致意想不到的结果,需要注意一下。
当数据从signal传送到subscriber时,还可以通过doXXX来做点事情,比如打印数据。
有些地方需要注意下,比如把signal作为local变量时,如果没有被subscribe,那么方法执行完后,该变量会被dealloc。但如果signal有被subscribe,那么subscriber会持有该signal,直到signal sendCompleted或sendError时,才会解除持有关系,signal才会被dealloc。
RACCommand
RACCommand是RAC很重要的组成部分,可以节省很多时间并且让你的App变得更Robust,这篇文章可以帮助你更深入的理解,这里简单做一下介绍。
RACCommand 通常用来表示某个Action的执行,比如点击Button。它有几个比较重要的属性:executionSignals / errors / executing。
1、executionSignals是signal of signals,如果直接subscribe的话会得到一个signal,而不是我们想要的value,所以一般会配合switchToLatest。
3、executing表示该command当前是否正在执行。
除了与UIControl绑定之外,也可以手动执行某个command,比如双击图片点赞,就可以这么实现。
常用的模式
map + switchToLatest
switchToLatest: 的作用是自动切换signal of signals到最后一个,比如之前的command.executionSignals就可以使用switchToLatest:。
map:的作用很简单,对sendNext的value做一下处理,返回一个新的值。
如果把这两个结合起来就有意思了,想象这么个场景,当用户在搜索框输入文字时,需要通过网络请求返回相应的hints,每当文字有变动时,需要取消上一次的请求,就可以使用这个配搭。这里用另一个Demo,简单演示一下
takeUntil
takeUntil:someSignal 的作用是当someSignal sendNext时,当前的signal就sendCompleted,someSignal就像一个拳击裁判,哨声响起就意味着比赛终止。
它的常用场景之一是处理cell的button的点击事件,比如点击Cell的详情按钮,需要push一个VC,就可以这样:
如果不加takeUntil:cell.rac_prepareForReuseSignal,那么每次Cell被重用时,该button都会被addTarget:selector。
替换Delegate
使用Reactiveviewmodel的didBecomActiveSignal
ReactiveViewModel是另一个project, 后面的MVVM中会讲到,通常的做法是在VC里设置VM的active属性(RVMviewmodel自带该属性),然后在VM里subscribeNext didBecomActiveSignal,比如当Active时,获取TableView的最新数据。
RACSubject的使用场景
//HBCviewmodel.h
rac_signalForSelector
rac_signalForSelector: 这个方法会返回一个signal,当selector执行完时,会sendNext,也就是当某个方法调用完后再额外做一些事情。用在category会比较方便,因为Category重写父类的方法时,不能再通过[super XXX]来调用父类的方法,当然也可以手写Swizzle来实现,不过有了rac_signalForSelector:就方便多了。
rac_signalForSelector: fromProtocol: 可以直接实现对protocol的某个方法的实现(听着有点别扭呢),比如,我们想实现UIScrollViewDelegate的某些方法,可以这么写
注意,这里的delegate需要先设置为nil,再设置为self,而不能直接设置为self,如果self已经是该scrollView的Delegate的话。
MVVM
这是一个大话题,如果有耐心,且英文还不错的话,可以看一下Cocoa Samurai的这
两篇文章。PS: Facebook Paper就是基于MVVM构建的。
MVVM是Model-View-viewmodel的简称,它们之间的关系如下
可以看到View(其实是ViewController)持有viewmodel,这样做的好处是viewmodel更加独立且可测试,viewmodel里不应包含任何View相关的元素,哪怕换了一个View也能正常工作。而且这样也能让View/ViewController「瘦」下来。
viewmodel主要做的事情是作为View的数据源,所以通常会包含网络请求。
或许你会疑惑,ViewController哪去了?在MVVM的世界里,ViewController已经成为了View的一部分。它的主要职责是将VM与View绑定、响应VM数据的变化、调用VM的某个方法、与其他的VC打交道。
我的思路是横向滚动的大图是一个collectionView,该collectionView是当前页面VC的一个property。底部可以滑动的缩略图是一个childVC的collectionView,这两个collectionView共用一套VM,并且各自RACObserve感兴趣的property。
//childVC
//PinsViewController.m
这里有一个小技巧,当Cell里的元素比较复杂时,我们可以给Cell也准备一个viewmodel,这个Cellviewmodel可以由上一层的viewmodel提供,这样Cell如果需要相应的数据,直接跟Cellviewmodel要即可,Cellviewmodel也可以包含一些command,比如likeCommand。假如点击Cell时,要做一些处理,也很方便。
//Cellviewmodel已经在viewmodel里准备好了
viewmodel中signal,property,command的使用
初次使用RAC+MVVM时,往往会疑惑,什么时候用signal,什么时候用property,什么时候用command?
一般来说可以使用property的就直接使用,没必要再转换成signal,外部RACObserve即可。使用signal的场景一般是涉及到多个property或多个signal合并为一个signal。command往往与UIControl/网络请求挂钩。
常见场景的处理
检查本地缓存,如果失效则去请求网络数据并缓存到本地
检测用户名是否可用
void)setupUsernameAvailabilityChecking{
RAC(self,availabilityStatus)=[[[RACObserve(self.userTemplate,username)
throttle:kUsernameCheckThrottleInterval]//throttle表示interval时间内如果有sendNext,则放弃该nextValue
map:^(NSString*username){
if(username.length==0)return[RACSignalreturn:@(UsernameAvailabilityCheckStatusEmpty)];
return[[[[[FIBAPIClientsharedInstance]
getUsernameAvailabilityFor:usernameignoreCache:NO]
map:^(NSDictionary*result){
NSNumber*existsNumber=result[@"exists"];
if(!existsNumber)return@(UsernameAvailabilityCheckStatusFailed);
UsernameAvailabilityCheckStatusstatus=[existsNumberboolValue]?UsernameAvailabilityCheckStatusUnavailable:UsernameAvailabilityCheckStatusAvailable;
return@(status);
}]
catch:^(NSError*error){
return:@(UsernameAvailabilityCheckStatusFailed)];
}]startWith:@(UsernameAvailabilityCheckStatusChecking)];
}]
switchToLatest];
}
RACSignal*requestSignal=[RACSignalcreateSignal:^RACDisposable*(id<RACSubscriber>subscriber){
//supposefirsttimesendrequest,accesstokenisexpiredorinvalid
//andnexttimeitiscorrect.
//theblockwillbetriggeredtwice.
staticBOOLisFirstTime=0;
NSString*url=@"http://httpbin.org/ip";
if(!isFirstTime){
url=@"http://nonexists.com/error";
isFirstTime=1;
}
NSLog(@"url:%@",url);
[[AFHTTPRequestOperationManagermanager]GET:urlparameters:nilsuccess:^(AFHTTPRequestOperation*operation,idresponseObject){
[subscribersendNext:responseObject];
[subscribersendCompleted];
}failure:^(AFHTTPRequestOperation*operation,NSError*error){
[subscribersendError:error];
}];
returnnil;
}];
self.statusLabel.text=@"sendingrequest...";
[[requestSignalcatch:^RACSignal*(NSError*error){
self.statusLabel.text=@"oops,invalidaccesstoken";
//simulatenetworkrequest,andwefetchtherightaccesstoken
return[[RACSignalcreateSignal:^RACDisposable*(id<RACSubscriber>subscriber){
doubledelayInSeconds=1.0;
dispatch_time_tpopTime=dispatch_time(DISPATCH_TIME_NOW,(int64_t)(delayInSeconds*NSEC_PER_SEC));
dispatch_after(popTime,dispatch_get_main_queue(),^(void){
[subscribersendNext:@YES];
[subscribersendCompleted];
});
returnnil;
}]concat:requestSignal];
}]subscribeNext:^(idx){
if([xisKindOfClass:[NSDictionaryclass]]){
self.statusLabel.text=[NSStringstringWithFormat:@"result:%@",x[@"origin"]];
}
}completed:^{
NSLog(@"completed");
}];
可以看到这里也使用了map + switchToLatest模式,这样就可以自动取消上一次的网络请求。
startWith的内部实现是concat,这里表示先将状态置为checking,然后再根据网络请求的结果设置状态。
使用takeUntil:来处理Cell的button点击
这个上面已经提到过了。
token过期后自动获取新的
开发APIClient时,会用到AccessToken,这个Token过一段时间会过期,需要去请求新的Token。比较好的用户体验是当token过期后,自动去获取新的Token,拿到后继续上一次的请求,这样对用户是透明的。
注意事项
RAC我自己感觉遇到的几个难点是: 1) 理解RAC的理念。 2) 熟悉常用的API。3) 针对某些特定的场景,想出比较合理的RAC处理方式。不过看多了,写多了,想多了就会慢慢适应。下面是我在实践过程中遇到的一些小坑。
ReactiveCocoaLayout
有时Cell的内容涉及到动态的高度,就会想到用Autolayout来布局,但RAC已经为我们准备好了
ReactiveCocoaLayout,所以我想不妨就拿来用一下。
ReactiveCocoaLayout的使用好比「批地」和「盖房」,先通过insetWidth:height:nullRect从某个View中划出一小块,拿到之后还可以通过divideWithAmount:padding:fromEdge 再分成两块,或sliceWithAmount:fromEdge再分出一块。这些方法返回的都是signal,所以可以通过RAC(self.view,frame) = someRectSignal 这样来实现绑定。但在实践中发现性能不是很好,多批了几块地就容易造成主线程卡顿。
所以ReactiveCocoaLayout最好不用或少用。
调试
刚开始写RAC时,往往会遇到这种情况,满屏的调用栈信息都是RAC的,要找出真正出现问题的地方不容易。曾经有一次在使用[RACSignal combineLatest: reduce:^id{}]时,忘了在Block里返回value,而Xcode也没有提示warning,然后就是莫名其妙地挂起了,跳到了汇编上,也没有调用栈信息,这时就只能通过最古老的注释代码的方式来找到问题的根源。
不过写多了之后,一般不太会犯这种低级错误。
strongify / weakify dance
因为RAC很多操作都是在Block中完成的,这块最常见的问题就是在block直接把self拿来用,造成block和self的retain cycle。所以需要通过@strongify和@weakify来消除循环引用。
有些地方很容易被忽略,比如RACObserve(thing,keypath),看上去并没有引用self,所以在subscribeNext时就忘记了weakify/strongify。但事实上RACObserve总是会引用self,即使target不是self,所以只要有RACObserve的地方都要使用weakify/strongify。
小结
以上是我在做花瓣客户端和side project时总结的一些经验,但愿能带来一些帮助,有误的地方也欢迎指正和探讨。
推荐一下jspahrsummers的这个
project,虽然是用RAC3.0写的,但很多理念也可以用到RAC2上面。
最后感谢Github的iOS工程师们,感谢你们带来了RAC,以及在Issues里的耐心解答。