大纲
- 面临的问题是什么?
- 解决方案是什么?
- 如何实施?
- 效果怎么样?
- 如何避免重蹈覆辙?
1. 现状
Cocoa的MVC模式驱使人们写出臃肿的视图控制器,因为它们经常被混杂到View的生命周期中,因此很难说View和ViewController是分离的。尽管仍可以将业务逻辑和数据转换到Model,但是大多数情况下当需要为View减负的时候我们却无能为力了,View的最大的任务就是向Controller传递用户动作事件。ViewController最终会承担一切代理和数据源的职责,还负责一些分发和取消网络请求以及一些其他的任务,因此就不难理解苹果为什么给取名ViewController了。
在我们项目中可能会看见过很多这样的代码:
PlantCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];
if (!cell) {
cell = [[PlantCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier];
}
PlantModel *model = [self.dataSource objectAtIndex:indexPath.row];
[cell configCellWithModel:model];
这个cell,正是由View直接来调用Model,事实上已经违背了MVC的原则。但是这种情况是一直发生的,甚至于我们不觉得这里有哪些不对。如果严格遵守MVC的话,你会把对cell的设置放在 Controller 中,不向View传递一个Model对象,这样就会大大增加Controller的体积,所以我们的项目中经常看到一个controller代码量超过2000行,实际维护起来非常麻烦。比如要修改一个点击事件,翻了半天终于找到了,定睛一看,竟然是网络请求。
“Cocoa 的MVC被写成Massive View Controller 是不无道理的。”
直到进行单元测试的时候才会发现问题越来越明显。因为你的ViewController和View是紧密耦合的,对它们进行测试就显得很艰难,你得有足够的创造性来模拟View和它们的生命周期,在以这样的方式来写View Controller的同时,业务逻辑的代码也逐渐被分散到View的布局代码中去。这也是业界对iOS开发者普遍不写单元测试的诟病的吐槽之一吧。
2. 该如何入手(MVVM)
简介
MVVM,Model-View-ViewModel,一个从 MVC 模式中进化而来的设计模式,最早于2005年被微软的 WPF 和 Silverlight 的架构师 John Gossman 提出。在 iOS 开发中实践 MVVM 的话,通常会把大量原来放在 ViewController 里的视图逻辑和数据逻辑移到 ViewModel 里,从而有效的减轻了 ViewController 的负担。另外通过分离出来的 ViewModel 获得了更好的测试性,我们可以针对 ViewModel 来测试,解决了界面元素难于测试的问题。MVVM 通常还会和一个强大的绑定机制一同工作,一旦 ViewModel 所对应的 Model 发生变化时,ViewModel 的属性也会发生变化,而相对应的 View 也随即产生变化。
优点
方便测试。在MVC下,Controller基本是无法测试的,里面混杂了个各种逻辑,而且分散在不同的地方。有了MVVM我们就可以测试里面的viewModel,来验证我们的处理结果对不对。
便于代码的移植。比如我们运营app和运维app,部分功能除了交互展示不一样外,业务逻辑的model是一致的。这样,我们就可以以很小的代价去开发另一个app。。
兼容MVC。MVVM是MVC的一个升级版,目前的MVC也可以很快的转换到MVVM这个模式。VC可以省去一大部分展示逻辑。
缺点:
MVVM 的学习成本和开发成本都很高。MVVM 是一个年轻的设计模式,大多数人对它的了解都不如对 MVC 熟悉,基于绑定机制来进行编程需要一定的学习才能较好的上手。同时在 iOS 客户端开发中,并没有现成的绑定机制可以使用,要么使用 KVO,要么引入类似 RxSwift或ReactiveCocoa 这样的第三方库,使得学习成本和开发成本进一步提高,但RxSwift也更能简化代码,这样可以放更多的时间到业务流程开发中。
数据绑定使 Debug 变得更难了。数据绑定使程序异常能快速的传递到其他位置,在界面上发现的 Bug 有可能是由 ViewModel 造成的,也有可能是由 Model 层造成的,传递链越长,对 Bug 的定位就越困难。
在传统的 MVVM 架构中,ViewModel 依然承载大量的逻辑,包括业务逻辑,界面逻辑,数据存储和网络相关,使得 ViewModel 仍然有可能变得和 MVC 中 ViewController 一样臃肿。
3. 实施
项目目录结构按照MVVM的分层方式进行了修改,主要划分为View,ViewModel,Model和Service。
![项目目录结构](http://upl
![Uploading Simulator Screen Shot 2017年1月7日 11.45.38_716403.png . . .]
oad-images.jianshu.io/upload_images/925877-f7ddeaa2cd069b64.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
以前的网络请求是单独封装了一个网络请求类工具类,需要调用网络请求的地方到处调用该方法,代码如下
- 工具类
+ (void)post:(NSString *)url params:(NSDictionary *)params success:(void (^)(id json))success failure:(void (^)(NSError *error))failure {
if (ISEMPTY(params[@"curPage"])) {
dispatch_async(dispatch_get_main_queue(), ^{
[HTLoading showGrayLoading];
});
}
[self checkNetwork:failure];
// 2.发送请求
NSMutableDictionary *mutableParams = [NSMutableDictionary dictionaryWithDictionary:params];
[mutableParams setValue:HTAPI_APPKEY forKey:@"appkey"];
[mutableParams setValue:NSLocalizedString(@"Language", nil) forKey:@"language"];
[mutableParams setValue:CONF_GET(@"token") forKey:@"token"];
AFHTTPSessionManager *sessionManager = [self sharedClient];
//设置请求头,这些参数根据不同的页面或者不同的网络会发生变化
[sessionManager.requestSerializer setValue:[mutableParams description] forHTTPHeaderField:@"oper_info"];
[sessionManager.requestSerializer setValue:url forHTTPHeaderField:@"oper_url"];
[sessionManager.requestSerializer setValue:[Utils getIPAddress] forHTTPHeaderField:@"login_ip"];
[sessionManager POST:url parameters:mutableParams progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
NSError *error = [NSError errorWithDomain:CustomErrorDomain code:XDefultFailed userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"返回数据异常", nil)}];
if (success) {
[HTLoading hideLoading];
// 请求成功,返回失败数据
if (responseObject == nil || [responseObject[@"result_code"] integerValue] != 1) {
NSLog(@"error:%@", mutableParams);
#ifdef DEBUG
NSString *info = [NSString stringWithFormat:@"%@:%ld,%@",NSLocalizedString(@"错误代码",nil),(long)error.code, [error.userInfo objectForKey:NSLocalizedDescriptionKey]];
#else
NSString *info = [error.userInfo objectForKey:NSLocalizedDescriptionKey];
#endif
ShowToastLong(NSLocalizedString(info, nil));
failure(error);
} else {
success(responseObject);
}
} else {
failure(error);
}
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
NSLog(@"error:%@ param = %@", error, mutableParams);
[HTLoading hideLoading];
#ifdef DEBUG
NSString *info = [NSString stringWithFormat:@"%@:%ld,%@",NSLocalizedString(@"错误代码",nil),(long)error.code, [error.userInfo objectForKey:NSLocalizedDescriptionKey]];
#else
NSString *info = [error.userInfo objectForKey:NSLocalizedDescriptionKey];
#endif
ShowToastLong(NSLocalizedString(info, nil));
if (failure) {
failure(error);
}
}];
}
- 调用方法
//调用接口服务请求参数初始化
NSMutableDictionary *params = [NSMutableDictionary dictionaryWithDictionary:@{@"service":@"getPsList", @"org_id":_org_id, @"user_id":CONF_GET(@"user_id"), @"curPage":[NSString stringWithFormat:@"%ld", (long)_curPage]}];
NSLog(@"plant_list_req %@",params);
if(self.sort_name){
[params setValue:self.sort_name forKey:@"sort_column"];
}
if (self.sortType) {
[params setValue:self.sortType forKey:@"sort_type"];
}
[HTHttpTool postPathWithParams:params success:^(id json) {
[self.tableView.mj_header endRefreshing];
//正常处理数据
NSMutableArray *tempArray = [HTPlant mj_objectArrayWithKeyValuesArray:[json[@"result_data"] objectForKey:@"pageList"]];
self.psArray = tempArray;
if (self.psArray.count < 1) {
self.emptyView.hidden = NO;
} else {
self.emptyView.hidden = YES;
}
[self.tableView reloadData];
} failure:^(NSError *error) {
[self.tableView.mj_header endRefreshing];
self.emptyView.hidden = NO;
self.emptyView.title.text = NSLocalizedString(@"下拉重试", nil);
}];
- 然后绑定数据到view上,整个过程相当臃肿。
改进方法
- 首先定义一个Service,对应着接口的一个Service,比如我们的APPService,我们对应采用相关的service,在该service中我们只需要实现对应的TargetType即可
import Foundation
import RxSwift
import Moya
import Alamofire
enum AppService {
case Login(user_account: String, user_password: String, sys_code: String, login_type: String)
case GetPsList(org_id: String, user_id: String, device_type: String, curPage: String, size: String)
}
extension AppService: TargetType {
var baseURL: URL {
return URL(string: "https://api.isolarcloud.com/sungws")!
}
var path: String {
return "/AppService";
}
var method: Moya.Method {
return .post
}
var parameters: [String: Any]? {
switch self {
case .Login(let user_account, let user_password, let sys_code, let login_type):
return ["service": "login", "user_account": user_account, "user_password": user_password, "sys_code": sys_code, "login_type": login_type]
case .GetPsList(let org_id, let user_id, let device_type, let curPage, let size):
return ["service": "getPsList", "org_id": org_id, "user_id": user_id, "device_type": device_type, "curPage": curPage, "size": size]
}
}
var sampleData: Data {
switch self {
case .Login:
return "".data(using: String.Encoding.utf8)!
case .GetPsList(_, _, _, _, _):
return "Create post successfully".data(using: String.Encoding.utf8)!
}
}
var task: Task {
return .request
}
}
let headerFields: Dictionary<String, String> = [
"User-Agent": "sungrow-agent",
"system": "iOS",
"sys_ver": String(UIDevice.version())
]
let appendedParams: Dictionary<String, String> = [
"appkey": appkey,
"language": "_zh_CN"
]
let endpointClosure = { (target: AppService) -> Endpoint<AppService> in
let defaultEndpoint = MoyaProvider<AppService>.defaultEndpointMapping(for: target)
return defaultEndpoint.adding(parameters: appendedParams, httpHeaderFields: headerFields, parameterEncoding: JSONEncoding.default)
}
let appServiceProvider = RxMoyaProvider<AppService>(endpointClosure: endpointClosure)
- 在ViewModel中实现网络请求
import Foundation
import RxSwift
import Moya
let defaut_curPage = "1"
let defaut_page_size = "20"
class ViewModel {
func login(user_account: String, user_password: String, sys_code: String, login_type: String) -> Observable<Login> {
return appServiceProvider.request(.Login(user_account: user_account, user_password: user_password, sys_code: sys_code, login_type: login_type))
.mapJSON()
.mapObject(type: Login.self)
}
func getPsList(org_id: String, user_id: String, device_type: String, curPage: String = defaut_curPage, size: String = defaut_page_size) -> Observable<[PlantStation]> {
return appServiceProvider.request(.GetPsList(org_id: org_id, user_id: user_id, device_type: device_type, curPage: curPage, size: size))
.mapJSON()
.mapArray(type: PlantStation.self)
}
}
- 在controller中调用ViewModel,绑定到对应的View中即可.
viewModel.getPsList(org_id: "79", user_id: "179", device_type: "1,4,7", curPage: "1")
.bindTo(tableView.rx.items(cellIdentifier: "Cell", cellType: UITableViewCell.self)) { (row, model, cell) in
cell.textLabel?.text = "\(model.ps_name ?? "") @ row \(row)"
}
.addDisposableTo(disposeBag)
4. 效果
Demo中采用了MVVM的方式进行了网络的初始化,网络的请求,数据的解析,以及数据的绑定,能够很清晰的找到每一个过程,不再像以前需要找一个网络请求半天找不到再哪里,而且轻松实现实现了数据的请求并显示到页面上
5. 避免重蹈覆辙
需要深刻理解MVVM架构的分层结构,尽量按照约定的分层进行代码开发。重新思考业务模型,抽象,抽象,在抽象。
- view层
- 具有共性的view单独抽出,避免相同的代码重复拷贝,建立项目的公用控件仓库
- 逻辑层
- 按照业务进行模块划分,一些跟具体业务无关的内容按照工具箱的思路进行封装,比如各种日期选择工具,网络加载等待,每个模块都封装独立的framework。
- 数据层
- 使用Moya网络分层,采用TargetType的protocol。
- 按照数据存储方式进行模块划分。