天天看點

ReactiveCocoa之實作篇

概述

為什麼要使用RAC?

一個怪怪的東西,從Demo看也沒有讓代碼變得更好、更短,相反還造成了解上的困難,真的有必要去學它麼?相信這是大多數人在接觸RAC時的想法。RAC不是單一功能的子產品,它是一個Framework,提供了一整套解決方案。其核心思想是「響應資料的變化」,在這個基礎上有了Signal的概念,進而可以幫助減少狀态變量(可以參考jspahrsummers的PPT),使用MVVM架構,統一的異步程式設計模型等等。

為什麼RAC更加适合編寫Cocoa App?說這個之前,我們先來看下Web前端程式設計,因為有些相似之處。目前很火的AngularJS有一個很重要的特性:資料與視圖綁定。就是當資料變化時,視圖不需要額外的處理,便可正确地呈現最新的資料。而這也是RAC的亮點之一。RAC與Cocoa的程式設計模式,有點像AngularJS和jQuery。是以要了解RAC,需要先在觀念上做調整。

以下面這個Cell為例

ReactiveCocoa之實作篇

正常的寫法可能是這樣,很直覺。

- (void)configureWithItem:(HBItem *)item

{

self.username.text = item.text;

[self.avatarImageView setImageWithURL: item.avatarURL];

// 其他的一些設定

}

但如果用RAC,可能就是這樣

- (id)init

{

if(self = [superinit]) {

@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的核心,為了幫助了解,畫了這張簡化圖

ReactiveCocoa之實作篇

這裡的資料源和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。

1、executionSignals是signal of signals,如果直接subscribe的話會得到一個signal,而不是我們想要的value,是以一般會配合switchToLatest。

2、errors。跟正常的signal不一樣,RACCommand的錯誤不是通過sendError來實作的,而是通過errors屬性傳遞出來的。

3、executing表示該command目前是否正在執行。

假設有這麼個需求:當圖檔載入完後,分享按鈕才可用。那麼可以這樣:

RACSignal *imageAvailableSignal = [RACObserve(self, imageView.image) map:id^(id x){returnx ? @YES : @NO}];

self.shareButton.rac_command = [[RACCommand alloc] initWithEnabled:imageAvailableSignal signalBlock:^RACSignal *(id input) {

// do share logic

}];

除了與UIControl綁定之外,也可以手動執行某個command,比如輕按兩下圖檔點贊,就可以這麼實作。

// ViewModel.m

- (instancetype)init

{

self = [superinit];

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();

}];

}

returnself;

}

// ViewController.m

- (void)viewDidLoad

{

[superviewDidLoad];

// ...

@weakify(self);

[RACObserve(self, viewModel.hasLiked) subscribeNex:^(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)returnsignal;

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);

returnsignal;

}

@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"

@classRACSubject;

@interfaceHBCViewModel : RVMViewModel

@property (nonatomic) RACSubject *errors;

@end

// HBCViewModel.m

#import "HBCViewModel.h"

#import 

@implementation HBCViewModel

- (instancetype)init

{

self = [superinit];

if(self) {

_errors = [RACSubject subject];

}

returnself;

}

- (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的簡稱,它們之間的關系如下

ReactiveCocoa之實作篇

可以看到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不一定能減少代碼量,但能降低代碼的複雜度。

以下面這個需求為例,要求大圖滑動結束時,底部的縮略圖滾動到對應的位置,并高亮該縮略圖;同時底部的縮略圖被選中時,大圖也要變成該縮略圖的大圖。

ReactiveCocoa之實作篇

我的思路是橫向滾動的大圖是一個collectionView,該collectionView是目前頁面VC的一個property。底部可以滑動的縮略圖是一個childVC的collectionView,這兩個collectionView共用一套VM,并且各自RACObserve感興趣的property。

比如大圖滑到下一頁時,會改變VM的indexPath屬性,而底部的collectionView所在的VC正好對該indexPath感興趣,隻要indexPath變化就滾動到相應的Item

// childVC

- (void)viewDidLoad

{

[superviewDidLoad];

@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

{

[superviewDidLoad];

@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];

returncell;

}

- (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 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[RACSignalreturn:@(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[RACSignalreturn:@(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 subscriber) {

// suppose first time send request, access token is expired or invalid

// and next time it is correct.

// the block will be triggered twice.

staticBOOL 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];

}];

returnnil;

}];

self.statusLabel.text = @"sending request...";

[[requestSignalcatch:^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 subscriber) {

doubledelayInSeconds = 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];

});

returnnil;

}] concat:requestSignal];

}] subscribeNext:^(id x) {

if([x isKindOfClass:[NSDictionaryclass]]) {

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最好不用或少用。

調試

ReactiveCocoa之實作篇

剛開始寫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裡的耐心解答。