前言
學習MVVM和ReactiveCocoa(簡稱RAC)也有一段時間了,不過都僅限於看部落格,一直對這兩個東西很感興趣,覺得很創新,也一直想找個機會在專案中實踐一下,但是還是有一些顧慮,畢竟沒有實踐過,網上的資料看的也有點雲裡霧裡,實際上手可能還是有一定的難度。於是決定寫一個簡單的demo實踐一下。我特意選擇了一個剛剛寫的專案中的一個介面來實現,為的是能從實際專案需求出發,看看換成MVVM+RAC該如何實現。(關於MVVM和ReactiveCocoa的基礎介紹我這裡就不在說了,網上有相關資料可以查閱)
所實現的功能
所實現的功能很簡單,就一個列表介面,UITableView搞定,可以下拉重新整理,上拉載入更多。最終的效果如下:
所採用的專案結構
Model:實體
View:Storyboard、xib和自定義view
ViewController:就是UIViewController了,我們要實現的介面對應的Controller就是ProductListViewController
ViewModel:(這個怎麼翻譯呢?檢視實體?)你們懂的。
API:網路請求相關
用到的第三方庫:
pod 'AFNetworking', '~> 2.5.3'
pod 'ReactiveCocoa', '~> 2.5'
pod 'MJRefresh', '~> 2.4.7'
pod 'MJExtension', '~> 2.5.9'
pod 'AFNetworking-RACExtensions', '~> 0.1.8'
除了AFNetworking和ReactiveCocoa,就是MJ大神的2個很受歡迎的類庫了,都是很常用的吧。(此處容我做個悲傷的表情,我開始寫這個demo的時候RAC3.0版本還只是alpha、beta版本,所以我用了2.0最終的一個正式版2.5,但是在寫這篇文章的時候,我又pod search了一下,發現已經出到4.0alpha版本了,不知道4.0又有了哪些改動,但是我知道3.0版本里RACCommand被標記成了deprecate,由RACAction替代,用法應該差不多)
實現細節(MVVM與ReactiveCocoa結合)
獲取列表資料
我們都知道在MVVM裡,跟網路通訊相關的操作都是應該由ViewModel來處理的,所以在ProductListViewModel裡定義了一個RACCommand,我們叫:
/**
* 獲取資料Command
*/
@property (nonatomic, strong, readonly) RACCommand *fetchProductCommand;
在ViewModel的init方法裡對它進行初始化:
_fetchProductCommand = [[RACCommand alloc]initWithSignalBlock:^RACSignal *(id input) { return [[[APIClient sharedClient]
fetchProductWithPageIndex:@()]
takeUntil:self.cancelCommand.executionSignals];
}];
訂閱RACCommand,獲取資料後賦值給items(items是儲存所有資料的陣列,即tableView的dataSource)
@weakify(self);
[[_fetchProductCommand.executionSignals switchToLatest] subscribeNext:^(ResponseData *response) {
@strongify(self);
if (!response.success) {
[self.errors sendNext:response.error];
}
else {
self.items = [ProductListModel objectArrayWithKeyValuesArray:response.data];
self.page = response.page;
}
}];
再看ProductListViewController裡,訂閱ViewModel的items,有變化時就reload tableview。
[RACObserve(self.viewModel, items) subscribeNext:^(id x) {
@strongify(self);
[self.table reloadData];
}];
tableView的dataSource如下:
#pragma mark - UITableViewDataSource - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return ;
} - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.viewModel.items.count;
} - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
ProductListCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ProductListCell" forIndexPath:indexPath];
cell.viewModel = [self.viewModel itemViewModelForIndex:indexPath.row]; return cell;
}
再看自定義tableViewCell裡:
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder]; if (self) {
@weakify(self);
[RACObserve(self, viewModel) subscribeNext:^(id x) { @strongify(self);
self.productNameLabel.text = self.viewModel.ProductName;
self.bankNameLabel.text = self.viewModel.ProductBank;
self.profitLabel.text = self.viewModel.ProductProfit;
self.saleStatusLabel.text = self.viewModel.SaleStatusCn;
self.productTermLabel.text = self.viewModel.ProductTerm;
self.productAmtLabel.text = self.viewModel.ProductAmt; }];
} return self;
}
有RAC就是這麼方便,不要block回撥,更無須delegate。
獲取更多資料
上拉載入更多,MJ已經幫我們處理了。我們只需要在ViewModel裡定義一個載入更多資料的RACCommand供呼叫即可。這裡就不介紹了,具體可以看最終的demo。
UITableView 重新整理狀態切換
用過MJRefresh的都知道,不管是header還是footer,beginRefreshing後,獲取完資料後是需要呼叫endRefreshing來切換重新整理狀態的。用RAC來實現的話,我們可以訂閱RACCommand的executing訊號,如下:
@weakify(self)
[_viewModel.fetchProductCommand.executing subscribeNext:^(NSNumber *executing) {
NSLog(@"command executing:%@", executing);
if (!executing.boolValue) {
@strongify(self)
[self.table.header endRefreshing];
}
}];
上面差不多就是ViewModel和ViewController之前的邏輯互動,他們之間就是通過ReactiveCocoa這座橋來連線的。
關於http請求這塊,AFNetworking大家都比較熟悉用法了,AFNetworking-RACExtensions就是把AFNetworking裡的http請求轉成了RACSignal,在ReactiveCocoa的世界裡,一切都是Signal(不知道說的對不對╮(╯_╰)╭)。
我封裝了一個httpGet方法:
- (RACSignal *)httpGet:(NSString *)URLString parameters:(id)parameters {
return [[[self rac_GET:URLString parameters:parameters]
catch:^RACSignal *(NSError *error) {
//對Error進行處理
NSLog(@"error:%@", error);
//TODO: 這裡可以根據error.code來判斷下屬於哪種網路異常,分別給出不同的錯誤提示
return [RACSignal error:[NSError errorWithDomain:@"ERROR" code:error.code userInfo:@{@"Success":@NO, @"Message":@"Bad Network!"}]];
}]
reduceEach:^id(id responseObject, NSURLResponse *response){
NSLog(@"url:%@,resp:%@",response.URL.absoluteString,responseObject);
ResponseData *data = [ResponseData objectWithKeyValues:responseObject]; return data;
}];
}
裡面主要乾了兩件事,第一是錯誤處理(下面會講到),第二是對返回資料進行解析,一般都是把json資料轉成Model。
在實際專案中,基本上所有api介面的返回值格式都是統一的(不統一的話你可以去打服務端的人了),所以我定義了一個叫ResponseData的Model,這個Model裡有個NSObject型別的屬性,用來接收不同型別的值(陣列、物件(即字典)等)。這樣的話每個api介面根據實際情況對這個NSObject型別的屬性進行格式轉換即可,使用起來就很方便了。
錯誤處理
錯誤處理又可以分好幾種情況,比如:
1)網路錯誤(無網路,超時等)
2)伺服器端錯誤(404、500等)
3)業務邏輯錯誤
前兩種錯誤,都會進入RACCommand的errors訊號通道,在上面封裝的那個httpGet方法裡可以看到,我們catch了error,然後就可以根據error的code來區分是哪種錯誤,這麼區分的目的是給使用者展示不同的錯誤提示,更加友好。
而第三種“錯誤”其實服務端返回的也是一個正常的json字串,我們也是會將它解析成ResponseData物件,這個時候就得單獨判斷是否出現錯誤了。針對兩種不同的情況,如果要分開處理,那必然會有很多重複的程式碼,作為一個追求高質量程式碼的程式猿來說,這是不可取的方案(甚至是不能忍的)。我的處理方案是(參考了http://limboy.me/ios/2014/06/06/deep-into-reactivecocoa2.html中關於RACSubject的用法):
1)定義一個BaseViewModel作為所有ViewModel的基類
@interface BaseViewModel : NSObject @property (nonatomic) RACSubject *errors; /**
* 取消請求Command
*/
@property (nonatomic, strong, readonly) RACCommand *cancelCommand; @end
2)對RACCommand的errors進行合併:
[[RACSignal merge:@[_fetchProductCommand.errors, self.fetchMoreProductCommand.errors]] subscribe:self.errors];
3)在RACCommand的訂閱裡判斷是否出現error,如果有錯誤,手動send一個error。
@weakify(self);
[[_fetchProductCommand.executionSignals switchToLatest] subscribeNext:^(ResponseData *response) {
@strongify(self);
if (!response.success) {
[self.errors sendNext:response.error];
}
else {
self.items = [ProductListModel objectArrayWithKeyValuesArray:response.data];
self.page = response.page;
}
}];
4)ViewController裡對ViewModel裡的errors進行訂閱。
[_viewModel.errors subscribeNext:^(NSError *error) {
ResponseData *data = [ResponseData objectWithKeyValues:error.userInfo];
NSLog(@"something error:%@", data.keyValues);
//TODO: 這裡可以選擇一種合適的方式將錯誤資訊展示出來
}];
原則就是把所有的錯誤都統一到一個通道里,這樣只需要在一個地方處理就行了。
http請求cancel
我們在實現某些介面功能時,往往會在介面開啟後進行http請求,有時會顯示一個指示器告訴使用者正在請求資料。但是如果網路比較差的情況下(比如2G網),有時使用者可能覺得等的時間太長了,就點了返回,介面雖然是關閉了,但是對於那個http請求來說它還在繼續的。這個時候比較好的處理方式就是將那個http請求cancel掉。不用RAC的情況下,我們需要記錄每次發起http請求的NSURLSessionTask(如果你是用的AFNetworking的AFHTTPSessionManager的話),然後在Viewcontroller的dealloc裡呼叫【task cancel】來取消這個task,需要注意的時,task被cancel的時候會返回error,這個時候就需要判斷下errorCode來甄別是不是cancel,以免跟其他網路異常弄混。
那麼用ReactiveCocoa該怎麼實現http的cancel呢?好在AFNetworking-RACExtensions’已經幫我們封裝好了,我們只需要在ViewModel裡定義一個表示取消http請求的RACCommand(可以放到BaseViewModel裡),然後再必要的地方呼叫這個command即可,當然前提是我們在發起http請求的command裡設定瞭如下的程式碼:
_fetchProductCommand = [[RACCommand alloc]initWithSignalBlock:^RACSignal *(id input) { return [[[APIClient sharedClient]
fetchProductWithPageIndex:@()]
takeUntil:self.cancelCommand.executionSignals];
}];
核心點就在於takeUntil,它表示“一直執行直到…”,套用在我們這裡就是http請求一直執行,直到cancel命令被下達。經過測試可以發現完全能達到我們的目的。
PS:這裡額外介紹下如何模擬不穩定的網路。設定 -> 開發者 -> NETWORK LINK CONDITIONER,裡面有各種選項可供選擇,比如100% Loss,3G,Very Bad Network等,雖然沒有專業工具那麼強大,但是簡單模擬下異常網路也是足夠了。
Model與ViewModel的界定
這兩者關係說清晰也清晰,說不清晰也不清晰。
為什麼說清晰呢?因為Model是實體,一般就是一些屬性欄位而已,而ViewModel是介於ViewController於Model之間的橋樑,ViewModel裡有RACCommand,也會有一些業務邏輯(比如分頁處理,ViewController只需要呼叫fetchData或者fetchMoreData即可,無需知道現在顯示的是第幾頁)。
那為什麼又不清晰呢?在我這個demo裡有個自定義tablecell的ViewModel(ProductListCellViewModel),這裡面其實也就是一些屬性而已,跟ProductListModel基本上都是一樣的。所以遇到這種情況就比較迷惑,到底是拿Model當ViewModel用呢,還是分開冗餘一部分程式碼呢?而且http請求返回的資料一般就是ViewController需要顯示的資料(只是一般情況,也有需要額外處理的)。
到底該怎麼處理呢?說說我的理解:
1)從http請求獲得的資料,就是sourceData,而我們的Model就是作為sourceData而存在的,所以我更傾向於用Model來對映json資料。
2)ViewModel是拿到Model進行處理(有時可能不需要額外處理),然後提供給ViewController使用,比如直接顯示到View上。
這也真是MVVM框架的核心。所以ViewModel裡的items儲存的是Model的陣列。那麼問題又來了,既然items裡是Model,而ViewController又是通過ViewModel獲取sourceData,那從Model到ViewModel該在哪裡進行轉換呢?
我能想到的是3個方案:
1)使用Model解析json資料後,迴圈遍歷Model轉成ViewModel儲存到items裡。這種做法,items裡儲存的是ViewModel而不是Model,TableCell使用的時候直接拿items裡的ViewModel即可。
2)items儲存Model,TableCell直接使用Model。當Model跟ViewModel幾乎完全一致的情況下很有可能會出現這種情況。因為會覺得完全複製一個ViewModel出來不值,但是這又不太符合MVVM。
3)items儲存Model,TableCell獲取ViewModel時,通過Model初始化ViewModel。
我目前使用的是第3種方案,在ViewModel裡使用Model作為一個屬性,然後提供一些readonly的屬性並重寫其get方法(中間可以對資料進行一些格式化之類的)供介面使用。
遇到的坑
獨自學習RAC還是有一定的難度的,畢竟面對眾多RAC的api要想完全理解下來還是挺困難的。而且剛開始不熟悉的情況下很難針對某些特定的場景,想出比較合理的RAC處理方式(這句話是盜用別人的,但是我也深有體會)。
這裡列一下我寫這個demo時遇到的幾個坑吧,希望能幫別人繞過這些坑,也算是功德一件。
1)ViewModel裡用來儲存資料的陣列,不能使用NSMutableArray。原因是RAC是基於KVO的,而NSMutableArray的Add和Remove方法並不會給KVO傳送通知,因此對NSMutableArray進行RACObserve時,並不會達到我們想要的結果。(同理其他Mutable的也都不能用)
2)ViewModel裡給items賦值時,不能用_items=somearray,而是得用self.items。我開始是想在viewmodel裡定義一個readonly的items屬性(理論上也應該是readonly的,因為ViewController只負責從ViewModel拿資料而已),然後通過_items進行賦值,但是訂閱了viewmodel的items後死活收不到訊息。我一直感覺這不科學,也許是我的開啟方式不對,但是最終都沒有解決。這裡希望知道的人能不吝賜教,在下感激不盡。
3)實現可以cancel的http請求時,不能用replay,replayLast,replayLazily。關於這3者的區分可以參考這個,我覺得分析的很詳細。
總結
以上就是我的一次MVVM+RAC的實踐,初學MVVM和RAC,難免有些概念和理解有偏差,歡迎批評指正,也歡迎一起交流討論。為的是能更好的學習和進步!
這裡奉上我的demo原始碼:傳送門
(因為demo所用介面是實際專案介面,容我將其抹掉)