iOS-MVVM架构-界面与数据I/O逻辑分离的实践

看了十来篇关于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 简要思惟导图

首先看一下整个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的交互了。

Controller与 ViewModel的交互 & 模拟测试数据Input

首先咱们看看职位列表页面,职位列表页面是同类数据列表分行显示,这样的页面通常都有下拉刷新和分页上拉加载的需求,咱们须要请求服务器数据,根据服务器返回的结果来显示页面、给予用户提示或控制上下拉控件的显示和隐藏,前文已经讲到过,我把这一块也交给ViewModel来控制。

JoblistViewControllerJobListViewModel的交互逻辑大体以下图:

图片描述

职位列表页的初始化和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继承于BaseViewModelBaseViewModel声明了代理协议,上面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去优化咱们的逻辑呢?

利用MVVM优化用户与后台交互数据的逻辑 & 模拟测试数据 I/O

首先咱们看看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的编辑都是操做的jobViewModeljobInfo属性,由于咱们最终仍是要把整个职位Model交给jobViewModel去转换成json参数作更新或添加的网络操做,何不一开始就操做它的属性呢?点击职位列表的Cell进入职位编辑界面会将jobId传入JobViewController,发布则不会,因此layoutUI函数根据页面是否传入JobInfoId参数来控制rightBarButton的显示字符和是否须要请求职位详情,点击rightBarButton响应的

- (void)saveJob:(UIBarButtonItem *)sender;

函数一样根据JobInfoId是否存在传给JobViewModel相应的modelType参数后调用网络请求方法。这三个函数一样解决了全部数据输入输出相关的问题,其余的信息编辑过程对jobViewModel.jobInfo的修改就不在这里赘述了。

JobInfoViewcontroller与其关联的JobViewModel交互关系以下图:

图片描述

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;

两个方法都根据JobViewModelmodelType进行返回相应的参数完成相应的操做。与职位列表同样,JobViewModel一样能够构造测试数据输入给JobViewController,如上在后台接口尚未就绪的时候,能够经过

- (NSArray *)testArray;

方法直接返回模拟数据给Controller去显示或操做,而后在

paramsDic = self.jobInfo.mj_keyValues;//模型转字典

jobInfo模型转换为字典以后,直接观察保存/发布操做输出的jobInfo转换成的字典参数是否符合预期,这样就很轻松地不须要后台接口就能够独立完成整套界面的输入输出测试,正如文章一开始提到的--简化数据的I/O,个人构想是让Controller只管从ViewModel拿到数据去显示或者编辑,以后再交给ViewModel去处理新增、更新或者删除等等,如今看来,MVVM真的能够作到哦。

写在最后

MVVM并无多么复杂,它只是将MVC的分工更加细化,咱们彻底能够在不影响功能的同时将项目慢慢向MVVM演进,这也正是我目前正在作的事情,能够优化的地方还有许多,我会不断去思考和优化,而后与你们交流。

感谢阅读,但愿本文对你有帮助!

本人坐标杭州,后续我会陆续把工做中遇到的问题及解决方案分享出来,互相交流学习,本人QQ:815187811,欢迎结交[笑脸].

相关文章
相关标签/搜索