之前已經寫過兩篇關於ReactiveCocoa(以下簡稱RAC)的文章了,但主要也是在闡述基本的概念和使用,這篇文章將會從實戰的角度來看看RAC到底解決了哪些問題,帶來了哪些方便,以及遇到的一些坑。
概述
爲什麼要使用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.avatarImageView setImageWithURL: item.avatarURL];
// 其他的一些設置
}
但如果用RAC,可能就是這樣
- (id)init
{
if (self = [super init]) {
@weakify(self);
[RACObserve(self, viewModel) subscribeNext:^(HBItemViewModel *viewModel) {
@strongify(self);
self.username.text = viewModel.item.text;
[self.avatarImageView setImageWithURL: viewModel.item.avatarURL];
// 其他的一些設置
}];
}
}
也就是先把數據綁定,接下來只要數據有變化,就會自動響應變化。在這裏,每次viewModel改變時,內容就會自動變成該viewModel的內容。
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
來做點事情,比如打印數據。
通過這張圖可以看到,這非常像中學時學的函數,比如 f(x)
= y
,某一個函數的輸出又可以作爲另一個函數的輸入,比如 f(f(x))
= z
,這也正是「函數響應式編程」(FRP)的核心。
有些地方需要注意下,比如把signal作爲local變量時,如果沒有被subscribe,那麼方法執行完後,該變量會被dealloc。但如果signal有被subscribe,那麼subscriber會持有該signal,直到signal sendCompleted或sendError時,纔會解除持有關係,signal纔會被dealloc。
RACCommand
RACCommand
是RAC很重要的組成部分,可以節省很多時間並且讓你的App變得更Robust,這篇文章可以幫助你更深入的理解,這裏簡單做一下介紹。
RACCommand
通常用來表示某個Action的執行,比如點擊Button。它有幾個比較重要的屬性:executionSignals
/ errors / executing。
-
executionSignals
是signal of signals,如果直接subscribe的話會得到一個signal,而不是我們想要的value,所以一般會配合switchToLatest
。 -
errors
。跟正常的signal不一樣,RACCommand
的錯誤不是通過sendError
來實現的,而是通過errors
屬性傳遞出來的。 -
executing
表示該command當前是否正在執行。
假設有這麼個需求:當圖片載入完後,分享按鈕纔可用。那麼可以這樣:
RACSignal *imageAvailableSignal = [RACObserve(self, imageView.image) map:id^(id x){return x ? @YES : @NO}];
self.shareButton.rac_command = [[RACCommand alloc] initWithEnabled:imageAvailableSignal signalBlock:^RACSignal *(id input) {
// do share logic
}];
除了與UIControl
綁定之外,也可以手動執行某個command,比如雙擊圖片點贊,就可以這麼實現。
// ViewModel.m
- (instancetype)init
{
self = [super init];
if (self) {
void (^updatePinLikeStatus)() = ^{
self.pin.likedCount = self.pin.hasLiked ? self.pin.likedCount - 1 : self.pin.likedCount + 1;
self.pin.hasLiked = !self.pin.hasLiked;
};
_likeCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
// 先展示效果,再發送請求
updatePinLikeStatus();
return [[HBAPIManager sharedManager] likePinWithPinID:self.pin.pinID];
}];
[_likeCommand.errors subscribeNext:^(id x) {
// 發生錯誤時,回滾
updatePinLikeStatus();
}];
}
return self;
}
// ViewController.m
- (void)viewDidLoad
{
[super viewDidLoad];
// ...
@weakify(self);
[RACObserve(self, viewModel.hasLiked) subscribeNext:^(id x){
@strongify(self);
self.pinLikedCountLabel.text = self.viewModel.likedCount;
self.likePinImageView.image = [UIImage imageNamed:self.viewModel.hasLiked ? @"pin_liked" : @"pin_like"];
}];
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] init];
tapGesture.numberOfTapsRequired = 2;
[[tapGesture rac_gestureSignal] subscribeNext:^(id x) {
[self.viewModel.likeCommand execute:nil];
}];
}
再比如某個App要通過Twitter登錄,同時允許取消登錄,就可以這麼做 (source)
_twitterLoginCommand = [[RACCommand alloc] initWithSignalBlock:^(id _) {
@strongify(self);
return [[self
twitterSignInSignal]
takeUntil:self.cancelCommand.executionSignals];
}];
RAC(self.authenticatedUser) = [self.twitterLoginCommand.executionSignals switchToLatest];
常用的模式
map + switchToLatest
switchToLatest:
的作用是自動切換signal
of signals到最後一個,比如之前的command.executionSignals就可以使用switchToLatest:
。
map:
的作用很簡單,對sendNext
的value做一下處理,返回一個新的值。
如果把這兩個結合起來就有意思了,想象這麼個場景,當用戶在搜索框輸入文字時,需要通過網絡請求返回相應的hints,每當文字有變動時,需要取消上一次的請求,就可以使用這個配搭。這裏用另一個Demo,簡單演示一下
NSArray *pins = @[@172230988, @172230947, @172230899, @172230777, @172230707];
__block NSInteger index = 0;
RACSignal *signal = [[[[RACSignal interval:0.1 onScheduler:[RACScheduler scheduler]]
take:pins.count]
map:^id(id value) {
return [[[HBAPIManager sharedManager] fetchPinWithPinID:[pins[index++] intValue]] doNext:^(id x) {
NSLog(@"這裏只會執行一次");
}];
}]
switchToLatest];
[signal subscribeNext:^(HBPin *pin) {
NSLog(@"pinID:%d", pin.pinID);
} completed:^{
NSLog(@"completed");
}];
// output
// 2014-06-05 17:40:49.851 這裏只會執行一次
// 2014-06-05 17:40:49.851 pinID:172230707
// 2014-06-05 17:40:49.851 completed
takeUntil
takeUntil:someSignal
的作用是當someSignal
sendNext時,當前的signal就sendCompleted
,someSignal就像一個拳擊裁判,哨聲響起就意味着比賽終止。
它的常用場景之一是處理cell的button的點擊事件,比如點擊Cell的詳情按鈕,需要push一個VC,就可以這樣:
[[[cell.detailButton
rac_signalForControlEvents:UIControlEventTouchUpInside]
takeUntil:cell.rac_prepareForReuseSignal]
subscribeNext:^(id x) {
// generate and push ViewController
}];
如果不加takeUntil:cell.rac_prepareForReuseSignal
,那麼每次Cell被重用時,該button都會被addTarget:selector
。
替換Delegate
出現這種需求,通常是因爲需要對Delegate的多個方法做統一的處理,這時就可以造一個signal出來,每次該Delegate的某些方法被觸發時,該signal就會sendNext
。
@implementation UISearchDisplayController (RAC)
- (RACSignal *)rac_isActiveSignal {
self.delegate = self;
RACSignal *signal = objc_getAssociatedObject(self, _cmd);
if (signal != nil) return signal;
/* Create two signals and merge them */
RACSignal *didBeginEditing = [[self rac_signalForSelector:@selector(searchDisplayControllerDidBeginSearch:)
fromProtocol:@protocol(UISearchDisplayDelegate)] mapReplace:@YES];
RACSignal *didEndEditing = [[self rac_signalForSelector:@selector(searchDisplayControllerDidEndSearch:)
fromProtocol:@protocol(UISearchDisplayDelegate)] mapReplace:@NO];
signal = [RACSignal merge:@[didBeginEditing, didEndEditing]];
objc_setAssociatedObject(self, _cmd, signal, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return signal;
}
@end
代碼源於此文
使用ReactiveViewModel的didBecomActiveSignal
ReactiveViewModel是另一個project, 後面的MVVM中會講到,通常的做法是在VC裏設置VM的active
屬性(RVMViewModel自帶該屬性),然後在VM裏subscribeNext didBecomActiveSignal
,比如當Active時,獲取TableView的最新數據。
RACSubject的使用場景
一般不推薦使用RACSubject
,因爲它過於靈活,濫用的話容易導致複雜度的增加。但有一些場景用一下還是比較方便的,比如ViewModel的errors。
ViewModel一般會有多個RACCommand
,那這些commands如果出現error了該如何處理呢?比較方便的方法如下:
// HBCViewModel.h
#import "RVMViewModel.h"
@class RACSubject;
@interface HBCViewModel : RVMViewModel
@property (nonatomic) RACSubject *errors;
@end
// HBCViewModel.m
#import "HBCViewModel.h"
#import <ReactiveCocoa.h>
@implementation HBCViewModel
- (instancetype)init
{
self = [super init];
if (self) {
_errors = [RACSubject subject];
}
return self;
}
- (void)dealloc
{
[_errors sendCompleted];
}
@end
// Some Other ViewModel inherit HBCViewModel
- (instancetype)init
{
_fetchLatestCommand = [RACCommand alloc] initWithSignalBlock:^RACSignal *(id input){
// fetch latest data
}];
_fetchMoreCommand = [RACCommand alloc] initWithSignalBlock:^RACSignal *(id input){
// fetch more data
}];
[self.didBecomeActiveSignal subscribeNext:^(id x) {
[_fetchLatestCommand execute:nil];
}];
[[RACSignal
merge:@[
_fetchMoreCommand.errors,
_fetchLatestCommand.errors
]] subscribe:self.errors];
}
rac_signalForSelector
rac_signalForSelector:
這個方法會返回一個signal,當selector執行完時,會sendNext,也就是當某個方法調用完後再額外做一些事情。用在category會比較方便,因爲Category重寫父類的方法時,不能再通過[super
XXX]
來調用父類的方法,當然也可以手寫Swizzle來實現,不過有了rac_signalForSelector:
就方便多了。
rac_signalForSelector: fromProtocol:
可以直接實現對protocol的某個方法的實現(聽着有點彆扭呢),比如,我們想實現UIScrollViewDelegate的某些方法,可以這麼寫
[[self rac_signalForSelector:@selector(scrollViewDidEndDecelerating:) fromProtocol:@protocol(UIScrollViewDelegate)] subscribeNext:^(RACTuple *tuple) {
// do something
}];
[[self rac_signalForSelector:@selector(scrollViewDidScroll:) fromProtocol:@protocol(UIScrollViewDelegate)] subscribeNext:^(RACTuple *tuple) {
// do something
}];
self.scrollView.delegate = nil;
self.scrollView.delegate = self;
注意,這裏的delegate需要先設置爲nil,再設置爲self,而不能直接設置爲self,如果self已經是該scrollView的Delegate的話。
有時,我們想對selector的返回值做一些處理,但很遺憾RAC不支持,如果真的有需要的話,可以使用Aspects
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打交道。
而RAC爲MVVM帶來很大的便利,比如RACCommand
,
UIKit的RAC Extension等等。使用MVVM不一定能減少代碼量,但能降低代碼的複雜度。
以下面這個需求爲例,要求大圖滑動結束時,底部的縮略圖滾動到對應的位置,並高亮該縮略圖;同時底部的縮略圖被選中時,大圖也要變成該縮略圖的大圖。
我的思路是橫向滾動的大圖是一個collectionView,該collectionView是當前頁面VC的一個property。底部可以滑動的縮略圖是一個childVC的collectionView,這兩個collectionView共用一套VM,並且各自RACObserve感興趣的property。
比如大圖滑到下一頁時,會改變VM的indexPath屬性,而底部的collectionView所在的VC正好對該indexPath感興趣,只要indexPath變化就滾動到相應的Item
// childVC
- (void)viewDidLoad
{
[super viewDidLoad];
@weakify(self);
[RACObserve(self, viewModel.indexPath) subscribeNext:^(NSNumber *index) {
@strongify(self);
[self scrollToIndexPath];
}];
}
- (void)scrollToIndexPath
{
if (self.collectionView.subviews.count) {
NSIndexPath *indexPath = self.viewModel.indexPath;
[self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES];
[self.collectionView.subviews enumerateObjectsUsingBlock:^(UIView *view, NSUInteger idx, BOOL *stop) {
view.layer.borderWidth = 0;
}];
UIView *view = [self.collectionView cellForItemAtIndexPath:indexPath];
view.layer.borderWidth = kHBPinsNaviThumbnailPadding;
view.layer.borderColor = [UIColor whiteColor].CGColor;
}
}
當點擊底部的縮略圖時,上面的大圖也要做出變化,也同樣可以通過RACObserve indexPath來實現
// PinsViewController.m
- (void)viewDidLoad
{
[super viewDidLoad];
@weakify(self);
[[RACObserve(self, viewModel.indexPath)
skip:1]
subscribeNext:^(NSIndexPath *indexPath) {
@strongify(self);
[self.collectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES];
}];
}
這裏有一個小技巧,當Cell裏的元素比較複雜時,我們可以給Cell也準備一個ViewModel,這個CellViewModel可以由上一層的ViewModel提供,這樣Cell如果需要相應的數據,直接跟CellViewModel要即可,CellViewModel也可以包含一些command,比如likeCommand。假如點擊Cell時,要做一些處理,也很方便。
// CellViewModel已經在ViewModel裏準備好了
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
HBPinsCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:cellIdentifier forIndexPath:indexPath];
cell.viewModel = self.viewModel.cellViewModels[indexPath.row];
return cell;
}
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
{
HBCellViewModel *cellViewModel = self.viewModel.cellViewModels[indexPath.row];
// 對cellViewModel執行某些操作,因爲Cell已經與cellViewModel綁定,所以cellViewModel的改變也會反映到Cell上
// 或拿到cellViewModel的數據來執行某些操作
}
ViewModel中signal, property, command的使用
初次使用RAC+MVVM時,往往會疑惑,什麼時候用signal,什麼時候用property,什麼時候用command?
一般來說可以使用property的就直接使用,沒必要再轉換成signal,外部RACObserve即可。使用signal的場景一般是涉及到多個property或多個signal合併爲一個signal。command往往與UIControl/網絡請求掛鉤。
常見場景的處理
檢查本地緩存,如果失效則去請求網絡數據並緩存到本地
- (RACSignal *)loadData {
return [[RACSignal
createSignal:^(id<RACSubscriber> subscriber) {
// If the cache is valid then we can just immediately send the
// cached data and be done.
if (self.cacheValid) {
[subscriber sendNext:self.cachedData];
[subscriber sendCompleted];
} else {
[subscriber sendError:self.staleCacheError];
}
}]
// Do the subscription work on some random scheduler, off the main
// thread.
subscribeOn:[RACScheduler scheduler]];
}
- (void)update {
[[[[self
loadData]
// Catch the error from -loadData. It means our cache is stale. Update
// our cache and save it.
catch:^(NSError *error) {
return [[self updateCachedData] doNext:^(id data) {
[self cacheData:data];
}];
}]
// Our work up until now has been on a background scheduler. Get our
// results delivered on the main thread so we can do UI work.
deliverOn:RACScheduler.mainThreadScheduler]
subscribeNext:^(id data) {
// Update your UI based on `data`.
// Update again after `updateInterval` seconds have passed.
[[RACSignal interval:updateInterval] take:1] subscribeNext:^(id _) {
[self update];
}];
}];
}
檢測用戶名是否可用
- (void)setupUsernameAvailabilityChecking {
RAC(self, availabilityStatus) = [[[RACObserve(self.userTemplate, username)
throttle:kUsernameCheckThrottleInterval] //throttle表示interval時間內如果有sendNext,則放棄該nextValue
map:^(NSString *username) {
if (username.length == 0) return [RACSignal return:@(UsernameAvailabilityCheckStatusEmpty)];
return [[[[[FIBAPIClient sharedInstance]
getUsernameAvailabilityFor:username ignoreCache:NO]
map:^(NSDictionary *result) {
NSNumber *existsNumber = result[@"exists"];
if (!existsNumber) return @(UsernameAvailabilityCheckStatusFailed);
UsernameAvailabilityCheckStatus status = [existsNumber boolValue] ? UsernameAvailabilityCheckStatusUnavailable : UsernameAvailabilityCheckStatusAvailable;
return @(status);
}]
catch:^(NSError *error) {
return [RACSignal return:@(UsernameAvailabilityCheckStatusFailed)];
}] startWith:@(UsernameAvailabilityCheckStatusChecking)];
}]
switchToLatest];
}
可以看到這裏也使用了map
+ switchToLatest
模式,這樣就可以自動取消上一次的網絡請求。
startWith
的內部實現是concat
,這裏表示先將狀態置爲checking,然後再根據網絡請求的結果設置狀態。
使用takeUntil:來處理Cell的button點擊
這個上面已經提到過了。
token過期後自動獲取新的
開發APIClient時,會用到AccessToken,這個Token過一段時間會過期,需要去請求新的Token。比較好的用戶體驗是當token過期後,自動去獲取新的Token,拿到後繼續上一次的請求,這樣對用戶是透明的。
RACSignal *requestSignal = [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
// suppose first time send request, access token is expired or invalid
// and next time it is correct.
// the block will be triggered twice.
static BOOL isFirstTime = 0;
NSString *url = @"http://httpbin.org/ip";
if (!isFirstTime) {
url = @"http://nonexists.com/error";
isFirstTime = 1;
}
NSLog(@"url:%@", url);
[[AFHTTPRequestOperationManager manager] GET:url parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {
[subscriber sendNext:responseObject];
[subscriber sendCompleted];
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
[subscriber sendError:error];
}];
return nil;
}];
self.statusLabel.text = @"sending request...";
[[requestSignal catch:^RACSignal *(NSError *error) {
self.statusLabel.text = @"oops, invalid access token";
// simulate network request, and we fetch the right access token
return [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
double delayInSeconds = 1.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
[subscriber sendNext:@YES];
[subscriber sendCompleted];
});
return nil;
}] concat:requestSignal];
}] subscribeNext:^(id x) {
if ([x isKindOfClass:[NSDictionary class]]) {
self.statusLabel.text = [NSString stringWithFormat:@"result:%@", x[@"origin"]];
}
} completed:^{
NSLog(@"completed");
}];
注意事項
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。