學習MVVM和ReactiveCocoa(簡稱RAC)也有一段時間了,不過都僅限于看博客,一直對這兩個東西很感興趣,覺得很創新,也一直想找個機會在項目中實踐一下,但是還是有一些顧慮,畢竟沒有實踐過,網上的資料看的也有點云里霧里,實際上手可能還是有一定的難度。于是決定寫一個簡單的demo實踐一下。我特意選擇了一個剛剛寫的項目中的一個界面來實現,為的是能從實際項目需求出發,看看換成MVVM+RAC該如何實現。(關于MVVM和ReactiveCocoa的基礎介紹我這里就不在說了,網上有相關資料可以查閱)
所實現的功能很簡單,就一個列表界面,UITableView搞定,可以下拉刷新,上拉加載更多。最終的效果如下:
Model:實體
View:Storyboard、xib和自定義view
ViewController:就是UIViewController了,我們要實現的界面對應的Controller就是PRoductListViewController
ViewModel:(這個怎么翻譯呢?視圖實體?)你們懂的。
API:網絡請求相關
用到的第三方庫:
1 pod 'AFNetworking', '~> 2.5.3'2 pod 'ReactiveCocoa', '~> 2.5'3 pod 'MJRefresh', '~> 2.4.7'4 pod 'MJExtension', '~> 2.5.9'5 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里,跟網絡通信相關的操作都是應該由ViewModel來處理的,所以在ProductListViewModel里定義了一個RACCommand,我們叫:
1 /**2 * 獲取數據Command3 */4 @property (nonatomic, strong, readonly) RACCommand *fetchProductCommand;
在ViewModel的init方法里對它進行初始化:
1 _fetchProductCommand = [[RACCommand alloc]initWithSignalBlock:^RACSignal *(id input) {2 3 return [[[APIClient sharedClient]4 fetchProductWithPageIndex:@(1)]5 takeUntil:self.cancelCommand.executionSignals];6 }];
訂閱RACCommand,獲取數據后賦值給items(items是保存所有數據的數組,即tableView的dataSource)
1 @weakify(self); 2 [[_fetchProductCommand.executionSignals switchToLatest] subscribeNext:^(ResponseData *response) { 3 @strongify(self); 4 if (!response.success) { 5 [self.errors sendNext:response.error]; 6 } 7 else { 8 self.items = [ProductListModel objectArrayWithKeyValuesArray:response.data]; 9 self.page = response.page;10 }11 }];
再看ProductListViewController里,訂閱ViewModel的items,有變化時就reload tableview。
1 [RACObserve(self.viewModel, items) subscribeNext:^(id x) {2 @strongify(self);3 [self.table reloadData];4 }];
tableView的dataSource如下:
1 #pragma mark - UITableViewDataSource 2 3 - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { 4 return 1; 5 } 6 7 - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { 8 return self.viewModel.items.count; 9 }10 11 - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {12 ProductListCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ProductListCell" forIndexPath:indexPath];13 cell.viewModel = [self.viewModel itemViewModelForIndex:indexPath.row];14 15 return cell;16 }
再看自定義tableViewCell里:
1 - (id)initWithCoder:(NSCoder *)aDecoder { 2 self = [super initWithCoder:aDecoder]; 3 4 if (self) { 5 @weakify(self); 6 [RACObserve(self, viewModel) subscribeNext:^(id x) { 7 8 @strongify(self); 9 self.productNameLabel.text = self.viewModel.ProductName;10 self.bankNameLabel.text = self.viewModel.ProductBank;11 self.profitLabel.text = self.viewModel.ProductProfit;12 self.saleStatusLabel.text = self.viewModel.SaleStatusCn;13 self.productTermLabel.text = self.viewModel.ProductTerm;14 self.productAmtLabel.text = self.viewModel.ProductAmt;15 16 }];17 }18 19 return self;20 }
有RAC就是這么方便,不要block回調,更無須delegate。
上拉加載更多,MJ已經幫我們處理了。我們只需要在ViewModel里定義一個加載更多數據的RACCommand供調用即可。這里就不介紹了,具體可以看最終的demo。
用過MJRefresh的都知道,不管是header還是footer,beginRefreshing后,獲取完數據后是需要調用endRefreshing來切換刷新狀態的。用RAC來實現的話,我們可以訂閱RACCommand的executing信號,如下:
1 @weakify(self)2 [_viewModel.fetchProductCommand.executing subscribeNext:^(NSNumber *executing) {3 NSLog(@"command executing:%@", executing);4 if (!executing.boolValue) {5 @strongify(self)6 [self.table.header endRefreshing];7 }8 }];
上面差不多就是ViewModel和ViewController之前的邏輯交互,他們之間就是通過ReactiveCocoa這座橋來連接的。
關于http請求這塊,AFNetworking大家都比較熟悉用法了,AFNetworking-RACExtensions就是把AFNetworking里的http請求轉成了RACSignal,在ReactiveCocoa的世界里,一切都是Signal(不知道說的對不對╮(╯_╰)╭)。
我封裝了一個httpGet方法:
1 - (RACSignal *)httpGet:(NSString *)URLString parameters:(id)parameters { 2 return [[[self rac_GET:URLString parameters:parameters] 3 catch:^RACSignal *(NSError *error) { 4 //對Error進行處理 5 NSLog(@"error:%@", error); 6 //TODO: 這里可以根據error.code來判斷下屬于哪種網絡異常,分別給出不同的錯誤提示 7 return [RACSignal error:[NSError errorWithDomain:@"ERROR" code:error.code userInfo:@{@"Success":@NO, @"Message":@"Bad Network!"}]]; 8 }] 9 reduceEach:^id(id responSEObject, NSURLResponse *response){10 NSLog(@"url:%@,resp:%@",response.URL.absoluteString,responseObject);11 ResponseData *data = [ResponseData objectWithKeyValues:responseObject];12 13 return data;14 }];15 }
里面主要干了兩件事,第一是錯誤處理(下面會講到),第二是對返回數據進行解析,一般都是把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的基類
1 @interface BaseViewModel : NSObject 2 3 @property (nonatomic) RACSubject *errors; 4 5 /** 6 * 取消請求Command 7 */ 8 @property (nonatomic, strong, readonly) RACCommand *cancelCommand; 9 10 @end
2)對RACCommand的errors進行合并:
1 [[RACSignal merge:@[_fetchProductCommand.errors, self.fetchMoreProductCommand.errors]] subscribe:self.errors];
3)在RACCommand的訂閱里判斷是否出現error,如果有錯誤,手動send一個error。
1 @weakify(self); 2 [[_fetchProductCommand.executionSignals switchToLatest] subscribeNext:^(ResponseData *response) { 3 @strongify(self); 4 if (!response.success) { 5 [self.errors sendNext:response.error]; 6 } 7 else { 8 self.items = [ProductListModel objectArrayWithKeyValuesArray:response.data]; 9 self.page = response.page;10 }11 }];
4)ViewController里對ViewModel里的errors進行訂閱。
1 [_viewModel.errors subscribeNext:^(NSError *error) {2 ResponseData *data = [ResponseData objectWithKeyValues:error.userInfo];3 NSLog(@"something error:%@", data.keyValues);4 //TODO: 這里可以選擇一種合適的方式將錯誤信息展示出來5 }];
原則就是把所有的錯誤都統一到一個通道里,這樣只需要在一個地方處理就行了。
我們在實現某些界面功能時,往往會在界面打開后進行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里設置了如下的代碼:
1 _fetchProductCommand = [[RACCommand alloc]initWithSignalBlock:^RACSignal *(id input) {2 3 return [[[APIClient sharedClient]4 fetchProductWithPageIndex:@(1)]5 takeUntil:self.cancelCommand.executionSignals];6 }];
核心點就在于takeUntil,它表示“一直執行直到…”,套用在我們這里就是http請求一直執行,直到cancel命令被下達。經過測試可以發現完全能達到我們的目的。
PS:這里額外介紹下如何模擬不穩定的網絡。設置 -> 開發者 -> NETWORK LINK CONDITIONER,里面有各種選項可供選擇,比如100% Loss,3G,Very Bad Network等,雖然沒有專業工具那么強大,但是簡單模擬下異常網絡也是足夠了。
這兩者關系說清晰也清晰,說不清晰也不清晰。
為什么說清晰呢?因為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所用接口是實際項目接口,容我將其抹掉)
新聞熱點
疑難解答