iOS架构升级

2017-01-07  本文已影响81人  derekibw

大纲

  1. 面临的问题是什么?

1. 现状

Cocoa的MVC模式驱使人们写出臃肿的视图控制器,因为它们经常被混杂到View的生命周期中,因此很难说View和ViewController是分离的。尽管仍可以将业务逻辑和数据转换到Model,但是大多数情况下当需要为View减负的时候我们却无能为力了,View的最大的任务就是向Controller传递用户动作事件。ViewController最终会承担一切代理和数据源的职责,还负责一些分发和取消网络请求以及一些其他的任务,因此就不难理解苹果为什么给取名ViewController了。

1452152425723031.png

在我们项目中可能会看见过很多这样的代码:

    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模式和MVVM模式的差别

优点

  1. 方便测试。在MVC下,Controller基本是无法测试的,里面混杂了个各种逻辑,而且分散在不同的地方。有了MVVM我们就可以测试里面的viewModel,来验证我们的处理结果对不对。

  2. 便于代码的移植。比如我们运营app和运维app,部分功能除了交互展示不一样外,业务逻辑的model是一致的。这样,我们就可以以很小的代价去开发另一个app。。

  3. 兼容MVC。MVVM是MVC的一个升级版,目前的MVC也可以很快的转换到MVVM这个模式。VC可以省去一大部分展示逻辑。

缺点:

  1. MVVM 的学习成本和开发成本都很高。MVVM 是一个年轻的设计模式,大多数人对它的了解都不如对 MVC 熟悉,基于绑定机制来进行编程需要一定的学习才能较好的上手。同时在 iOS 客户端开发中,并没有现成的绑定机制可以使用,要么使用 KVO,要么引入类似 RxSwift或ReactiveCocoa 这样的第三方库,使得学习成本和开发成本进一步提高,但RxSwift也更能简化代码,这样可以放更多的时间到业务流程开发中。

  2. 数据绑定使 Debug 变得更难了。数据绑定使程序异常能快速的传递到其他位置,在界面上发现的 Bug 有可能是由 ViewModel 造成的,也有可能是由 Model 层造成的,传递链越长,对 Bug 的定位就越困难。

  3. 在传统的 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);
    }];

改进方法

    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)

    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)
        }
    }
    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架构的分层结构,尽量按照约定的分层进行代码开发。重新思考业务模型,抽象,抽象,在抽象。
  1. view层
    • 具有共性的view单独抽出,避免相同的代码重复拷贝,建立项目的公用控件仓库
  2. 逻辑层
    • 按照业务进行模块划分,一些跟具体业务无关的内容按照工具箱的思路进行封装,比如各种日期选择工具,网络加载等待,每个模块都封装独立的framework。
  3. 数据层
    • 使用Moya网络分层,采用TargetType的protocol。
    • 按照数据存储方式进行模块划分。
上一篇 下一篇

猜你喜欢

热点阅读