看了十來篇關於MVVM的文章以後,終於開始有信心在本身的項目中嘗試採用MVVM這個架構了。git
最開始是由於公司要求寫單元測試。寫單元測試是一件比較痛苦的事情,尤爲是在項目已經成型以後。懶惰驅使我必須去了解有沒有更具吸引力的替代方式,碰巧看到一篇關於MVVM的文章,講到MVVM能將界面邏輯與業務邏輯分離開來,更方便測試,因而開始着重瞭解這個架構模式,看的越多,但是卻遲遲動不了手,總算通過這段時間的嘗試,終於感受能夠大膽使用了。程序員
前幾天抽時間寫了個Demo,如今將本身的片面理解分享出來與你們交流,請你們指正。github
之前寫項目習慣將網絡請求方法都在Controller裏,子類調用基類方法去請求數據,子類重寫基類參數方法提供不一樣參數,請求結果一樣回調到各個子類去處理。這樣縱向擴展致使邏輯混雜,Controller層代碼量大,最重要的是局部功能測試比較麻煩,不利於後期維護。json
本文講到的MVVM
則是將工程橫向擴展,將數據操做分工給ViewModel
,這樣細化以後代碼將更爲清晰簡潔,更利於維護和測試。數組
這兩天有一些讀者提議將Demo放到git上面去,因此抽空完善了一下,點此能夠下載MVVM-Master,別忘了star哦。服務器
簡化邏輯,無非就是簡化Controller數據的I/O,太多的MVVM文章講怎麼利用ViewModel請求數據來顯示在列表中,然而這只是數據的Input,咱們更須要的ViewModel能幫咱們完成完整的用戶交互,將Controller的全部網絡操做解放出來。下面我結合本身的理解,經過最近寫的一個關於職位列表的顯示和職位信息的發佈和更新的Demo,來說解一下我是怎麼讓ViewModel幫我清晰地完成Controller數據的Input和Output,以及它帶來的測試方便程度。網絡
首先看一下整個Demo的簡要思惟導圖:架構
如上圖所示,JobListViewController
(職位列表)和JobViewController
(職位詳情)都繼承於BaseTableViewController
類,BaseTableViewController
類提供了列表頁面公有的一些方法,例如顯示和隱藏用戶提示,上拉加載和下拉刷新控件的顯示和隱藏等方法,供子類或ViewModel調用,全部的列表頁面均可以繼承BaseTableViewController
類。框架
而BaseViewModel
則聲明瞭代理協議,供子類去與Controller關聯,並實現了協議方法去調用關聯代理的Controller的相關方法,供繼承於它的ViewModel去操做UI。這裏我爲了儘量地簡化Controller的處理邏輯,將須要根據返回的數據處理UI的操做也交給了ViewModel,這裏因人而異,看實際需求選擇不一樣的方式。ide
那麼咱們先來看看兩個基類的代碼:
BaseTableViewController.h
// // BaseTableViewController.h // MyOwnDemo // // Created by zhujiamin on 16/5/17. // Copyright © 2016年 zhujiamin@yaomaitong.cn. All rights reserved. // #import <UIKit/UIKit.h> #import "BaseViewModel.h" @interface BaseTableViewController : UIViewController<HUDshowMessageDelegate, UITableViewDelegate, UITableViewDataSource> //初始化方法 -(instancetype)initWithStyle:(UITableViewStyle)style; //用戶提示 - (void)showMessage:(NSString *)message WithCode:(NSString *)code; - (void)hideHUD; //刷新框架 - (void)addDefaultHeader; - (void)addDefaultFooter; - (void)HideFooter; - (void)endRefresh; //公共屬性 @property (nonatomic, strong) UITableView *tableview; @property (nonatomic, strong) NSMutableArray *dataArray; @end
BaseTableViewController.m
// // BaseTableViewController.m // MyOwnDemo // // Created by zhujiamin on 16/5/17. // Copyright © 2016年 zhujiamin@yaomaitong.cn. All rights reserved. // #import "BaseTableViewController.h" #import "MBProgressHUD.h" #import "MJRefresh.h" @interface BaseTableViewController () @property (nonatomic, strong) MBProgressHUD *hudText;//加載菊花 @property(nonatomic,assign)UITableViewStyle tableStyle;//列表樣式 @end @implementation BaseTableViewController - (instancetype)initWithStyle:(UITableViewStyle)style{ if (self = [super init]){ self.tableStyle = style; self.dataArray = [[NSMutableArray alloc]init]; } return self; } - (void)viewDidLoad { [super viewDidLoad]; [self.view addSubview:self.tableview]; } - (UITableView *)tableview{ if (!_tableview) { _tableview = [[UITableView alloc]initWithFrame:self.view.frame style:self.tableStyle]; _tableview.delegate = self; _tableview.dataSource = self; } return _tableview; } //加載框顯示 - (void)showMessage:(NSString *)message WithCode:(NSString *)code{ _hudText = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; _hudText.labelText = message; if (message.length) { [_hudText hide:YES afterDelay:1.5f]; } } //加載框隱藏 - (void)hideHUD{ [_hudText hide:YES]; } //添加下拉刷新 - (void)addDefaultHeader{ __unsafe_unretained __typeof(self) weakSelf = self; self.tableview.mj_header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{ [weakSelf loaddataWith:@"1"]; }]; } //添加上拉加載 - (void)addDefaultFooter{ if (!self.tableview.mj_footer) { self.tableview.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingTarget:self refreshingAction:@selector(loadNextPage)]; } } //結束加載狀態 - (void)endRefresh{ if (self.tableview.mj_header) { [self.tableview.mj_header endRefreshing]; } if (self.tableview.mj_footer) { [self.tableview.mj_footer endRefreshing]; } } //移除上拉加載 - (void)HideFooter{ if (self.tableview.mj_footer) { self.tableview.mj_footer = nil; } } - (void)loaddataWith:(NSString *)pageNo{ //子類需重寫 } - (void)loadNextPage{ //子類需重寫 } //子類重寫 #pragma mark UITableViewDataSource - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{ return 1; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{ return 1; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{ static NSString *cellIder = @"DEFAULT_CELL"; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIder]; if (!cell) { cell = [[UITableViewCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIder]; } return cell; } @end
BaseViewModel.h
// // BaseViewModel.h // MyOwnDemo // // Created by zhujiamin on 16/5/16. // Copyright © 2016年 zhujiamin@yaomaitong.cn. All rights reserved. // #import <Foundation/Foundation.h> typedef void(^requestSuccess)(NSArray *responseArray); typedef void(^requestFailure)(NSError *error); @protocol HUDshowMessageDelegate<NSObject> @optional //加載框控制 - (void)showMessage:(NSString *)message WithCode:(NSString *)code; - (void)hideHUD; //刷新控件控制 - (void)addDefaultFooter; - (void)HideFooter; - (void)endRefresh; @end @interface BaseViewModel : NSObject<HUDshowMessageDelegate> @property(nonatomic,weak) id<HUDshowMessageDelegate>delegate; @end
BaseViewModel.m
// // BaseViewModel.m // MyOwnDemo // // Created by zhujiamin on 16/5/16. // Copyright © 2016年 zhujiamin@yaomaitong.cn. All rights reserved. // #import "BaseViewModel.h" @implementation BaseViewModel - (void)showMessage:(NSString *)message WithCode:(NSString *)code{ if ([self.delegate respondsToSelector:@selector(showMessage:WithCode:)]) { [self.delegate showMessage:message WithCode:code]; } } - (void)hideHUD{ if ([self.delegate respondsToSelector:@selector(hideHUD)]) { [self.delegate hideHUD]; [self endRefresh]; } } - (void)addDefaultFooter{ if ([self.delegate respondsToSelector:@selector(addDefaultFooter)]) { [self.delegate addDefaultFooter]; } } - (void)HideFooter{ if ([self.delegate respondsToSelector:@selector(HideFooter)]) { [self.delegate HideFooter]; } } - (void)endRefresh{ if ([self.delegate respondsToSelector:@selector(endRefresh)]) { [self.delegate endRefresh]; } } @end
以上兩個基類,將子類的公有控件、屬性和須要調用的函數都已經準備好以供子類使用。由於不一樣的子類處理狀況不一樣,有些方法父類實現以後須要子類去重寫實現,不過若是頁面處理狀況都比較統一也能夠把方法抽象到基類,子類調用的時候傳入不一樣的參數,而後基類返回結果給各個子類。那麼咱們接下來就能夠安心寫好具體的Controller與ViewModel的交互了。
首先咱們看看職位列表頁面,職位列表頁面是同類數據列表分行顯示,這樣的頁面通常都有下拉刷新和分頁上拉加載的需求,咱們須要請求服務器數據,根據服務器返回的結果來顯示頁面、給予用戶提示或控制上下拉控件的顯示和隱藏,前文已經講到過,我把這一塊也交給ViewModel來控制。
JoblistViewController
與JobListViewModel
的交互邏輯大體以下圖:
JobListViewModel
的數據請求方法下面咱們重點來看一下職位列表頁的初始化和JobListViewModel
的數據請求方法。(界面的顯示在這裏再也不贅述)。
JoblistViewController.m
//初始化 - (instancetype)init{ if (self = [super init]) { self.jobListVM = [[JobListViewModel alloc]init]; self.jobListVM.delegate = self; self.dataArray = [[NSMutableArray alloc]init]; self.current = 1; } return self; } //重寫父類請求數據方法 - (void)loaddataWith:(NSString *)pageNo{ [self.jobListVM setPageNo:pageNo];//首頁加載能夠不設置 [self.jobListVM FetchDataWithSuccess:^(NSArray *responseArray) { if (responseArray.count) { if ([pageNo isEqualToString:@"1"]) { [self.dataArray removeAllObjects]; } [self.dataArray addObjectsFromArray:responseArray]; [self.tableview reloadData]; } } failureWithFailure:^(NSError *error) { //網絡錯誤相應的處理 }]; }
我在JobListViewController
初始化的同時初始化jobListVM
,並關聯代理,不論是首次進入加載、下拉刷新或是上拉加載,都直接調用loaddataWith:
方法,傳入相應的頁碼值便可,這裏的處理要取決於網絡架構,本Demo列表頁在初始化的同時初始化了一個頁碼參數current = 1,上拉加載執行的時候current++,後將current傳給後臺加載該頁碼的數據,將請求回來的數據加到dataArray尾部,而後reloadData,這樣整個JobListViewController
中的數據處理就只須要這一個函數,界面測試很是地容易,下面會講到在ViewModel中構造模擬數據輸入給Controller來進行界面測試。
須要說明的是,請求失敗的狀況咱們其實也能夠放在ViewModel來處理,本Demo並無處理這一塊,這裏要根據實際需求的不一樣去切換界面或者給用戶提示,總之無論怎樣均可以吧方法抽象到基類,本身調用或ViewModel調用均可以。(上下拉加載控件使用MJRefresh來實現,添加和移除方法均放在基類裏供ViewModel調用),那麼下面咱們看看如何實現的JobListViewModel
。
JobListViewModel.h
#import "BaseViewModel.h" @interface JobListViewModel : BaseViewModel<HUDshowMessageDelegate> @property(nonatomic, strong) NSString *pageNo; - (void)FetchDataWithSuccess:(requestSuccess)success failureWithFailure:(requestFailure)failure; @end
.h 文件暴露一個頁碼參數和一個請求數據函數,供外部調用。JobListViewModel
繼承於BaseViewModel
,BaseViewModel
聲明瞭代理協議,上面jobListVM
被建立的時候已經與JobListViewController
關聯了代理,這樣JobListViewModel
在請求到數據以後就能夠根據須要調用協議方法去操做UI。具體實現見下面.m文件:
JobListViewModel.m
#import "JobListViewModel.h" #import "KTProxy.h" #import "JobModel.h" #import "MJExtension.h" #define requestNum 10 @implementation JobListViewModel - (void)FetchDataWithSuccess:(requestSuccess)success failureWithFailure:(requestFailure)failure{ //構造輸入數據,進行測試 // NSMutableArray *dataArray = [[NSMutableArray alloc]init]; // for (int i = 0; i < 15; i++) { // JobModel *job = [[JobModel alloc]init]; // job.name = @"程序員鼓勵師"; // job.jobId = [NSString stringWithFormat:@"%d",i+1]; // job.showDate = @"五分鐘前"; // [dataArray addObject:job]; // } // // success(dataArray); // return; //上面用於輸入測試數據給Controller,下面是正常請求邏輯 [self showMessage:@"正在加載" WithCode:@""];//調用加載狂 KTProxy *proxy = [KTProxy loadWithMethod:[self method] andParams:[self params] completed:^(NSDictionary *respDict) { [self hideHUD];//隱藏加載框 if ([respDict[@"code"] integerValue]== 0) { NSArray *array = [JobModel mj_objectArrayWithKeyValuesArray:respDict[@"data"][@"result"]];//字典數組轉模型數組 //根據返回控制上拉加載的顯示和隱藏(這裏因項目的網絡架構而異) if (array.count == requestNum) { [self addDefaultFooter]; } else { [self HideFooter]; } success(array); } else { [self showMessage:respDict[@"message"] WithCode:respDict[@"code"]];//提示服務器返回的非正常信息 } } failed:^(NSError *error) { failure(error); [self hideHUD]; }]; [proxy start]; } //請求url - (NSString *)method{ return @"myrecruitment/list"; } //請求參數 - (NSDictionary *)params{ return @{@"pageNo":self.pageNo?self.pageNo:@"1", @"pageSize":[NSNumber numberWithInteger:requestNum]}; } @end
一些時候,在咱們客戶端開發的時候,後臺接口可能並無作好,這個時候咱們須要本身模擬數據來測試界面是否正常,如上方法實現中,for循環構造十五條職位信息返回給controller,而後return掉,再也不走網絡請求,這樣直接能夠測試界面邏輯,相比較老的MVC模式,邏輯清晰太多了,哪一塊出現問題了能夠很快地定位到問題所在,這就是MVVM最吸引個人地方,一個ViewModel能夠輕鬆地控制Controller數據的輸入輸出,達到單元測試的效果!
正常網絡請求調用的loadWithMethod:andParams:completed:
方法是我使用AFNetWorking
二次封裝在KTProxy
類中的公共網絡請求方法,返回時已經將服務器返回的data數據解析成json字典respDict返回,ViewModel這一層只須要處理respDict裏面的數據,作出相應的操做。Demo中返回的code==0表示成功,那麼我將數據解析成職位模型數組返回給controller去處理,code!=0時調用Controller的提示方法,將服務器返回的message字段告知給用戶,調用
- (void)showMessage:(NSString *)message WithCode:(NSString *)code;
方法時會將code傳入該方法,方便咱們統一處理與後臺協定好的code值去作一些頁面操做。
本Demo使用MJExtension
實現字典與模型的互相轉換,比之前的JSONModel
要輕量許多,效果以下:
單一的列表數據顯示並不能看出多少優化點,那麼下面咱們來看看相對較爲複雜的職位編輯頁面JobInfoViewController
,由上圖能夠看出,從職位列表頁面進入職位詳情界面有兩個入口,一個是編輯職位(須要請求詳情),一個是發佈職位(不須要請求詳情),這意味着咱們須要與三個接口交互(edit(編輯)、add(新增)、update(更新)),那麼應該怎麼利用MVVM去優化咱們的邏輯呢?
首先咱們看看JobInfoViewController.m
的三個主要函數,initWithStyle
(初始化)、layoutUI
(頁面佈局)、saveJob
(保存編輯或發佈職位)函數。
JobInfoViewController.m
- (instancetype)initWithStyle:(UITableViewStyle)style{ if (self = [super initWithStyle:style]) { self.jobViewModel = [[JobViewModel alloc]init]; self.jobViewModel.delegate = self; } return self; } - (void)layoutUI{ self.view.backgroundColor = [UIColor whiteColor]; [self.tableview registerNib:[UINib nibWithNibName:@"InputCell" bundle:nil] forCellReuseIdentifier:@"infocell"]; self.dataArray = [@[@[@"公司名稱", @"職位名稱"], @[@"工做區域", @"職位類型", @"薪資待遇", @"工做經驗", @"學歷要求"], @[@"簡歷投遞"]]copy]; _placeholderArray = @[@[@"輸入公司名稱", @"輸入職位名稱"], @[@"輸入工做區域", @"輸入職位類型", @"輸入薪資待遇", @"輸入工做經驗", @"輸入學歷要求"], @[@"輸入郵箱(例如:123@163.com)"]]; NSString *rightButtonTitle; if (self.JobInfoId) { rightButtonTitle = @"保存"; [self.jobViewModel setJobId:self.JobInfoId]; [self.jobViewModel FetchDataWithSuccess:^(NSArray *responseArray) { if (responseArray.count) { self.jobViewModel.jobInfo = [responseArray firstObject]; [self.tableview reloadData]; } } failureWithFailure:^(NSError *error) { //網絡錯誤 }]; } else { rightButtonTitle = @"發佈"; } self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]initWithTitle:rightButtonTitle style:UIBarButtonItemStylePlain target:self action:@selector(saveJob:)]; } - (void)saveJob:(UIBarButtonItem *)sender{ self.jobViewModel.modelType = self.JobInfoId?1:2; [self.jobViewModel FetchDataWithSuccess:^(NSArray *responseArray) { //發佈或保存成功後的處理 [self.navigationController popViewControllerAnimated:YES]; } failureWithFailure:^(NSError *error) { //網絡錯誤 }]; }
如上能夠看到,跟上面的JobListViewController
同樣,JobInfoViewController
頁面初始化的時候jobViewModel
同時初始化,只不過jobViewModel
初始化的同時會初始化自身攜帶的jobInfo
屬性,整個JobViewController
的編輯都是操做的jobViewModel
的jobInfo
屬性,由於咱們最終仍是要把整個職位Model交給jobViewModel
去轉換成json參數作更新或添加的網絡操做,何不一開始就操做它的屬性呢?點擊職位列表的Cell進入職位編輯界面會將jobId
傳入JobViewController
,發佈則不會,因此layoutUI
函數根據頁面是否傳入JobInfoId
參數來控制rightBarButton
的顯示字符和是否須要請求職位詳情,點擊rightBarButton
響應的
- (void)saveJob:(UIBarButtonItem *)sender;
函數一樣根據JobInfoId
是否存在傳給JobViewModel
相應的modelType
參數後調用網絡請求方法。這三個函數一樣解決了全部數據輸入輸出相關的問題,其餘的信息編輯過程對jobViewModel.jobInfo
的修改就不在這裏贅述了。
JobInfoViewcontroller
與其關聯的JobViewModel
交互關係以下圖:
下面咱們看一下JobViewModel的具體實現:
JobViewModel.h
#import "BaseViewModel.h" #import "JobModel.h" @interface JobViewModel : BaseViewModel<HUDshowMessageDelegate> @property(nonatomic, strong) NSString *jobId; @property(nonatomic, strong) JobModel *jobInfo; @property(nonatomic) NSInteger modelType;//1.更新 2.發佈 - (void)FetchDataWithSuccess:(requestSuccess)success failureWithFailure:(requestFailure)failure; @end
JobViewModel.m
#import "JobViewModel.h" #import "KTProxy.h" #import "JobModel.h" #import "MJExtension.h" @implementation JobViewModel - (instancetype)init{ if (self = [super init]) { self.jobInfo = [[JobModel alloc]init]; } return self; } - (void)FetchDataWithSuccess:(requestSuccess)success failureWithFailure:(requestFailure)failure{ //構造測試數據 success(self.testArray); return; //網絡請求 [self showMessage:@"正在加載" WithCode:@""];//菊花 KTProxy *proxy = [KTProxy loadWithMethod:[self method] andParams:[self params] completed:^(NSDictionary *respDict) { [self hideHUD]; if ([respDict[@"code"] integerValue] == 0) { if (self.modelType == 0) { self.jobInfo = [JobModel mj_objectWithKeyValues:respDict[@"data"]];//字典轉模型 success([NSArray arrayWithObject:self.jobInfo]); }else if (self.modelType == 1) { [self showMessage:@"發佈成功" WithCode:@"8888"]; success([NSArray new]); } else if (self.modelType == 2) { [self showMessage:@"更新成功" WithCode:@"8888"]; success([NSArray new]); } }else{ //提示錯誤 [self showMessage:respDict[@"message"] WithCode:respDict[@"code"]]; } } failed:^(NSError *error) { failure(error); [self hideHUD]; }]; [proxy start]; } //構造測試返回數據 - (NSArray *)testArray{ _jobInfo = [[JobModel alloc]init]; _jobInfo.name = @"程序員鼓勵師"; _jobInfo.companyName = @"杭州六倍體科技"; _jobInfo.provinceName = @"杭州西湖區"; _jobInfo.cityName = @"杭州"; _jobInfo.salaryName = @"面議"; _jobInfo.typeName = @"技術"; _jobInfo.experienceName = @"不限"; _jobInfo.email = @"815187811@qq.com"; _jobInfo.degreeName = @"本科以上"; _jobInfo.showDate = @"兩小時前"; _jobInfo.Description = @"期待你的加入"; return [NSArray arrayWithObject:_jobInfo]; } //請求url - (NSString *)method{ if (self.modelType == 2) { return @"recruitment/job/add";//發佈 } else if (self.modelType == 1) { return @"recruitment/job/update";//更新 } return @"myrecruitment/job/edit";//職位編輯 } //參數準備 - (NSDictionary *)params{ NSMutableDictionary *paramsDic; if (self.modelType) {//更新或者發佈 paramsDic = [[NSMutableDictionary alloc]init]; paramsDic = self.jobInfo.mj_keyValues;//模型轉字典 return @{@"requestObj":paramsDic}; } return @{@"requestObj":self.jobId};//編輯 } @end
如上能夠看出,由於整個JobViewController
包含了三個接口的交互,所以JobViewModel
的請求路徑和請求參數都要分爲三種狀況,如上
- (NSString *)method; - (NSDictionary *)params;
兩個方法都根據JobViewModel
的modelType
進行返回相應的參數完成相應的操做。與職位列表同樣,JobViewModel
一樣能夠構造測試數據輸入給JobViewController
,如上在後臺接口尚未就緒的時候,能夠經過
- (NSArray *)testArray;
方法直接返回模擬數據給Controller去顯示或操做,而後在
paramsDic = self.jobInfo.mj_keyValues;//模型轉字典
jobInfo
模型轉換爲字典以後,直接觀察保存/發佈操做輸出的jobInfo
轉換成的字典參數是否符合預期,這樣就很輕鬆地不須要後臺接口就能夠獨立完成整套界面的輸入輸出測試,正如文章一開始提到的--簡化數據的I/O
,個人構想是讓Controller
只管從ViewModel
拿到數據去顯示或者編輯,以後再交給ViewModel
去處理新增、更新或者刪除等等,如今看來,MVVM真的能夠作到哦。
MVVM並無多麼複雜,它只是將MVC的分工更加細化,咱們徹底能夠在不影響功能的同時將項目慢慢向MVVM演進,這也正是我目前正在作的事情,能夠優化的地方還有許多,我會不斷去思考和優化,而後與你們交流。
感謝閱讀,但願本文對你有幫助!
本人座標杭州,後續我會陸續把工做中遇到的問題及解決方案分享出來,互相交流學習,本人QQ:815187811,歡迎結交[笑臉].