iOS架构升级

大纲

  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);
    }];
  • 然后绑定数据到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架构的分层结构,尽量按照约定的分层进行代码开发。重新思考业务模型,抽象,抽象,在抽象。
  1. view层
    • 具有共性的view单独抽出,避免相同的代码重复拷贝,建立项目的公用控件仓库
  2. 逻辑层
    • 按照业务进行模块划分,一些跟具体业务无关的内容按照工具箱的思路进行封装,比如各种日期选择工具,网络加载等待,每个模块都封装独立的framework。
  3. 数据层
    • 使用Moya网络分层,采用TargetType的protocol。
    • 按照数据存储方式进行模块划分。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容