一次MVVM+ReactiveCocoa實踐


前言

學習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與ReactiveCocoa結合)

 

獲取列表數據

 

我們都知道在MVVM里,跟網絡通信相關的操作都是應該由ViewModel來處理的,所以在ProductListViewModel里定義了一個RACCommand,我們叫:

1 /**
2  *  獲取數據Command
3  */
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。

UITableView 刷新狀態切換

用過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請求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里設置了如下的代碼:

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的界定

這兩者關系說清晰也清晰,說不清晰也不清晰。

為什么說清晰呢?因為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所用接口是實際項目接口,容我將其抹掉)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM