對 RAC 中 RACCommand 的理解和應用
RACSignal 和 RACCommand
RACCommand 是 RAC 中的最複雜的一個類之一,它也是一種廣義上的訊號。RAC 中訊號其實是一種物件(或者是不同程式碼塊)之間通訊機制,在面向物件中,類之間的通訊方式主要是方法呼叫,而訊號也是一種呼叫,只不過它是函式式的,因此訊號不僅僅可以在物件之間相互呼叫(傳參),也可以在不同程式碼塊(block)之間進行呼叫。
一般來說,RAC 中用 RACSignal 來代表訊號。一個物件建立 RACSignal 訊號,建立訊號時會包含一個 block,這個 block 的作用是傳送訊號給訂閱者(類似方法返回值或回撥函式)。另一個物件(或同一個物件)可以用這個訊號進行訂閱,從而獲得傳送者傳送的資料。這個過程和方法呼叫一樣,訊號相當於暴露給其它物件的方法,訂閱者訂閱訊號相當於呼叫訊號中的方法(block),只不過返回值的獲得變成了通過 block 來獲得。此外,你無法直接向 RACSignal 傳遞引數,要向訊號傳遞引數,需要提供一個方法,將要傳遞的引數作為方法引數,建立一個訊號,通過 block 的捕獲區域性變數方式將引數捕獲到訊號的 block 中。
而 RACCommand 不同,RACCommand 的訂閱不使用 subscribeNext 方法而是用 execute: 方法。而且 RACCommand 可以在訂閱/執行(即 excute:方法)時傳遞引數。因此當需要向訊號傳遞引數的時候,RACComand 更好用。
此外,RACCommand 包含了一個 executionSignal 的訊號,這個訊號是對使用者透明的,它是自動建立的,由 RACCommand 進行管理。許多資料中把它稱之為訊號中的訊號,是因為這個訊號會發送其它訊號——即 RACCommand 在初始化的 signalBlock 中建立(return)的訊號。這個訊號是 RACCommand 建立時由我們建立的,一般是用於處理一些非同步操作,比如網路請求等。請看程式碼:
@weakify(self);
RACCommand *command = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(NSString* input) {
@strongify(self);
NSDictionary *body = @{@"memberCode": input};
// 進行網路操作,同時將這個操作封裝成訊號 return
return [RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber> _Nonnull subscriber) {
// 一些網路操作
...
return nil;
}];
}];
在這段程式碼中,需要注意:
- initWithSignalBlock 方法初始化一個 RACCommand,這個方法需要提供一個 signalBlock 塊引數。
- signalBlock 塊的簽名中,有一個入參 input,它是訂閱者在訂閱/執行(呼叫 RACCommand 的 execute: 方法)時傳入的。可以是任意型別(id),這裡我們為了簡單起見,定義為 NSString(它真的就是一個 NSString),從而減少型別轉換的程式碼。
- signalBlock 塊的返回值是一個訊號,因此在塊體中,我們用 createSignal 建立了一個訊號作為返回值。這是必須的,因為這個訊號中定義了一些我們需要進行的處理,比如網路請求等。真正的任務是在這個訊號中進行的,外部的訂閱者通過 execute: 方法訂閱/執行這個 RACCommand 時,這些程式碼就得以執行。這些程式碼的具體內容在這裡並不重要,請忽略。
從上面來看,其實用一個簡單的 RACSignal 也能完成同樣的工作。那為什麼還要用 RACCommand 呢?
這就是 RACCommand 的另一個優點了,它可以監聽 RACCommand 自身的執行狀態,比如開始、進行中、完成、錯誤等。用 RACSignal 可以監聽到完成(complete)、錯誤(error)、進行中(next)。但開始就無法實現了,而且實現起來程式碼比較分散和難看(吐槽一下,RAC 絕大多數時候並沒有為我們提供新功能,只不過是一種程式碼美學的處理而已)。
RACCommand 的解決辦法很簡單,就是用一個訊號來監聽另一個訊號的執行。也就是 executionSignal 訊號的來由。在本文中,我們會叫他外層訊號。而 signalBlock 中的那個訊號(真正執行主要工作的)則叫內層訊號。
理論足夠了,來做一些實驗吧。
開始
新建一個 Single View 專案。UI 很簡單,就一個 ViewController 類,上面包含了一個 UIButton 和 UITextView,隨便你怎麼佈局它們。然後建立相應的連線。
重要的是 ViewModel 類,我們用它簡單實現一個 MVVM 架構。在 ViewModel.h 裡宣告一些屬性:
@property(nonatomic, strong) RACCommand *requestData;
@property(nonatomic, assign) HTTPRequestStatus requestStatus;
@property (strong, nonatomic) NSDictionary *data;
@property (strong, nonatomic) NSError* error;
- requestData 就是本文的核心了,一個 RACCommand 類,提供一些訊號給 controller 用於更新 UI(比如小菊花)。
requestStatus 記錄網路請求的狀態,比如開始、完成、出錯等,它是一個列舉,定義如下:
typedef NS_ENUM(NSUInteger, HTTPRequestStatus) { HTTPRequestStatusBegin, HTTPRequestStatusEnd, HTTPRequestStatusError, };
- data 用於儲存成功請求後獲得的資料。
- error 用於儲存請求失敗後的錯誤。
接下來是實現了,首先看 RACCommand 的建立。
建立 RACCommand
在 ViewModel.m 中,我們通過懶載入方式來初始化 RACCommand 物件:
- (RACCommand *)requestData {
if (!_requestData) {
@weakify(self);
_requestData = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(NSString* input) {
@strongify(self);
NSDictionary *body = @{@"memberCode": input};
// 進行網路操作,同時將這個操作封裝成訊號 return
return [RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber> _Nonnull subscriber) {
[self postUrl:kSubscribeURL params:body requestType:@"json" success:^(id _Nullable responseObject) {
[subscriber sendNext:responseObject];
[subscriber sendCompleted];
} failure:^(NSError *error) {
[subscriber sendError:error];
}];
return nil;
}];
}];
}
return _requestData;
}
這段程式碼大部分都在前面講過了,只有 [self postUrl: … 這部分程式碼是新的。這部分程式碼是一個將 HTTP 請求封裝為 RACSignal 的典型示例,相信每個人都不陌生,你可以替換成自己的程式碼。
訂閱訊號
RACCommand 中封裝了各種訊號,我們只用到了外層訊號(executionSignal)和內層訊號。訂閱這些訊號能夠讓我們實現兩個目的:拿到請求返回的資料、跟蹤 RACCommand 開始結束狀態。定義一個方法來做這些事情:
- (void)subcribeCommandSignals {
@weakify(self)
// 1. 訂閱外層訊號
[self.requestData.executionSignals subscribeNext:^(RACSignal* innerSignal) {
@strongify(self)
// 2. 訂閱內層訊號
[innerSignal subscribeNext:^(NSDictionary* x) {
self.data = x;
self.requestStatus = HTTPRequestStatusEnd;
}];
self.error = nil;
self.requestStatus = HTTPRequestStatusBegin;
}];
// 3. 訂閱 errors 訊號
[self.requestData.errors subscribeNext:^(NSError * _Nullable x) {
@strongify(self)
self.error = x;
self.data = nil;
self.requestStatus = HTTPRequestStatusError; // 這一句必須放在最後一句,否者 controller 拿不到資料
}];
}
這裡需要注意:
- 訂閱外層訊號(即 executionSignals)。外層訊號在訂閱或執行(即 execute: )時傳送。因此我們可以將它視作請求即將開始之前的訊號,在這裡將 self.error 清空,將 requestStatus 修改為 begin。
- 訂閱內層訊號,因為內層訊號由外層訊號(executionSignals)作為資料傳送(sendNext:),而傳送的資料一般是作為 subcribeNext:時的 block 的引數來接收的,因此在這個塊中,塊的引數就是內層訊號。這樣我們就可以訂閱內層訊號了,同時獲取資料(儲存到 data 屬性)並修改 requestStatus 為 end。
- RACCommand 比較特殊的一點是 error 訊號需要在 errors 中訂閱,而不能在 executionSignals 中訂閱。在這裡我們訂閱了 errors 訊號,並修改 data、error 和 requestStatus 屬性值。
最後,在 init 方法中呼叫這個方法,來完成對相關訊號的訂閱。
- (id)init {
self = [super init];
if (self) {
[self subcribeCommandSignals];
}
return self;
}
訊號部分處理完了,接下來是 UI。
Controller
UI 需要關心 RACCommand 的開始、完成、失敗狀態,以便顯示隱藏小菊花,同時 UI 需要關心 RACCommand 獲取的資料並做展示(這裡為了簡單起見,直接用 Text View 顯示出資料)。這其實是對 ViewModel 中的 data 屬性和 requestStatus 屬性的監聽,因此,接下來的一步就是在 controller 中將 View 和 ViewModel 進行綁定了。繫結的程式碼如下:
-(void)bindViewModel{
@weakify(self)
// 1.
[[RACObserve(_viewModel, requestStatus) skip:1] subscribeNext:^(NSNumber* x) {
@strongify(self)
switch ([x intValue]) {
case HTTPRequestStatusBegin:
[MBProgressHUD showHUDAddedTo:self.view animated:YES];
break;
case HTTPRequestStatusEnd:
[MBProgressHUD hideHUDForView:self.view animated:YES];
break;
case HTTPRequestStatusError:
[MBProgressHUD hideHUDForView:self.view animated:YES];
[MBProgressHUD showError:self.viewModel.error.localizedDescription toView:self.view];
break;
}
}];
// 2.
RAC(self.textView,text) = [[RACObserve(_viewModel, data) skip:1] map:^id _Nullable(NSDictionary* value) {
return dic2str(value);
}];
// 3.
// _button.rac_command = _viewModel.requestData;
[[_button rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(__kindof UIControl * _Nullable x) {
@strongify(self)
[self.viewModel.requestData execute:@"96671e1a812e46dfa4264b9b39f3e225"];
}];
}
- 監聽 ViewModel 的 requestStatus 屬性,當屬性為 begin 時顯示小菊花,當屬性為 end 時隱藏小菊花,當屬性為 error 時隱藏小菊花並顯示錯誤訊息。這裡需要注意的是,RAC 在第一次繫結時會自動傳送一條訊號,這時 requestStatus 的初始值是預設值 0,這樣的訊息顯然是多餘的,我們要用 skip:1 過濾掉。
- 將 ViewModel 的 data 屬性和 textView 進行繫結。因為 data 是一個 NSDictionary,而 textView 的 text 屬性是 NSString,顯然無法做這樣的繫結,於是我們用 map: 方法把 data 從 NSDictionary 轉換為 NSString。這裡的 dic2str 便利函式替我們完成這個工作。同樣 RACObserve 會在第一次繫結時傳送一條多餘訊號,我們用 skip:1 過濾掉。
- 訂閱按鈕的 rac 訊號進行事件處理。這裡沒有使用 _button.rac_command = _viewModel.requestData 這樣的方式,雖然它看起來比較簡單,但卻無法在呼叫 RACCommand 時傳遞引數。因此我們手工訂閱了按鈕的 rac 訊號,並在訂閱塊中手動呼叫了 execute: 方法,以此來傳遞了一個字串引數。作為演示,這裡的引數傳遞的是一個常量,你可以傳入任意值(id型別)。
最後,在 viewDidLoad 方法中,我們需要初始化 ViewModel,並呼叫 bindViewModel 方法:
- (void)viewDidLoad {
[super viewDidLoad];
_viewModel = [ViewModel new];
[self bindViewModel];
}
注: 這裡使用的 RAC 是 ReactiveObjC 3.0,如果你使用了 ReactiveCocoa 的其它版本,有的程式碼可能需要修改。