iOS-MVVM模式

公司最近开始新的app项目,一直都在看mvvm,所以准备开发一个mvvm模式的app。

什么是MVVM?

mvvm属于设计模式的一种,Model-View-ViewModel的简写。是属于对mvc架构的一种优化,用于抽离mvc中过于复杂的Controller。
mvvm应用响应式编程的理念,采用绑定的方法将ViewModel中的值与对应的View或者Controller中相关控件进行关联。最终目的是将页面或网络数据集合在一个实体类里,增加中间层,使每个类的目标更加明确。

mvvm流程图

在MVVM模式中,需要实现的最为关键的一点就是保证数据的即时传输,即绑定的实现

KVO,全称Key-Value-Observer,观察者模式,苹果官方给出的绑定模式

这个方法就是addObserver: forKeyPath: options: context:
我们在这里稍微写一个实现方法

//创建一个类(这个类主要是作为被观察的对象,只包含一个属性,所以.m文件暂时就不写在上面了)
@interface ExampleObject : (NSObject)
@property(nonatomic,copy) NSString *example;
@end
@interface ViewController ()
@property (nonatomic,strong) ExampleObject *object;
@end
@implementation ViewController 
//这里是在ViewController中的实现方法
- (void)viewDidLoad {
    [super viewDidLoad];
    _object = [[ExampleObject alloc] init];
    //注意,这里一定要用被观察者来作为这个方法的调用者,同时将需要观察的属性作为字符串参数,传入KeyPath中,第三个参数一般传NSKeyValueObservingOptionNew或NSKeyValueObservingOptionOld,最后一个参数通常传空,这里传入什么到时候便会在context参数中接受到
    //一定要在dealloc方法中取消观察者,否则将会产生崩溃
    [_object addObserver:self forKeyPath:@"example" options:NSKeyValueObservingOptionNew context:nil];
    _object.example = @"1111";
    _object.example = @"2222";
}
//这个方法就是观察者接收到值的变化后会收到的调用方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    //keyPath 就是被观察者的属性名称 object 就是被观察的对象 change就是一个字典中包含新旧值
    NSString *example = change[NSKeyValueChangeNewKey];
    NSLog(@"字典中:%@",example);
    ExampleObject *object1 = object;
    NSString *example_Lastest = object1.example;
    NSLog(@"对象 %@ ,example:%@",object1,example_Lastest);
}
- (void)dealloc {
    [_object removeObserver:self forKeyPath:@"example"];
}
@end

NSKeyValueObservingOptionNew: //表明变化的字典应该提供新的属性值,如果可以的话。
NSKeyValueObservingOptionOld: //表明变化的字典应该包含旧的属性值,如果可以的话。
NSKeyValueObservingOptionInitial:// 如果被指定,一个通知会立刻发送到观察者,甚至在观察者注册方法之前就返回,改变的字典需要包含一个 NSKeyValueChangeNewKey 入口,如果 NSKeyValueObservingOptionNew 也被指定的话,但从来不会包含一个NSKeyValueChangeOldKey 入口。(在一个 initial notification 里,观察者的当前属性可能是旧的,但对观察者来说是新的),你可以使用这个选项代替显式的调用,同时,代码也会被观察者的 observeValueForKeyPath:ofObject:change:context: 方法调用,当这个选项被用于 addObserver:forKeyPath:options:context:,一个通知将会发送到每个被观察者添加进去的索引对象中。
NSKeyValueObservingOptionPrior://是否各自的通知应该在每个改变前后发送到观察者,而不是在改变之后发送一个单独的通知。一个通知中的可变数组在改变发生之前发送经常包含一个 NSKeyValueChangeNotificationIsPriorKey 入口且它的值是 @YES,但从来不会包含一个 NSKeyValueChangeNewKey 入口。当这个选项被指定,在改变之后发送的通知中的变化的字典包含了一个与在选项没有被指定的情况下应该包含的同一个入口,当观察者自己的键值观察需要它的时候,你可以使用这个选项来调用 -willChange... 方法中的一个来观察它自己的某个属性,那个属性的值依赖于被观察的对象的属性。(在那种情况,调用 -willChange... 来对收到的一个observeValueForKeyPath:ofObject:change:context: 消息做出反应可能就太晚了)

这就是基于KVO的绑定的实现方法,KVO的实现方式实在过于繁琐,需要通过字符串的方式进行匹配,所以使这个方法的使用率相对较低。

RAC,全称ReactiveCocoa,是一个函数响应式编程框架,github上的三方开源框架,和MVVM模式的契合度非常高,使用起来非常舒服、自然

首先是RAC的使用,本文是基于Object-c的语言,使用cocoaPods引入RAC框架
使用cocoaPods的教程:点击查看
需要在podfile里面加入这句话pod 'ReactiveCocoa', '~> 2.5'(一定要选用2.5及以下的版本,更高版本是对swift的)
接下来我们就可以用了
最近通过个人使用,感觉用的比较多的几个函数和方法

  • RAC(<#TARGET, ...#>)这个是赋值的一个宏定义
    RAC(self,name) = textField.rac_textSignal;就相当于当textField接收到用户输入状态的时候,将test赋值给name属性
  • RACObserve(<#TARGET#>, <#KEYPATH#>)这个相当于系统的观察者模式,返回的是一个RACSignal,在值发生变化的时候这个signal会调用sendNext:
[RACObserve(self, name) subscribeNext:^(id x) {
    //在这个Block里面完成当值发生变化后触发的动作
}];
  • [[UITextField alloc] init].rac_textSignal这个rac_textSignal触发于当textField通过用户键盘被编辑的时候

我们现在来完成一个demo,模拟用户登录,以及获取数据更新tableView
我们创建好一个项目,自然就产生了一个ViewController,我们用这个ViewController当做登录的ViewController
创建一个ViewModel,名字是LoginViewModel
按照最基础的登录页面来做,页面中需要有两个输入框一个按钮
.h文件的实现

#import <Foundation/Foundation.h>

@interface LoginViewModel : NSObject

/**
 用户名
 */
@property (nonatomic,copy) NSString *name;

/**
 密码
 */
@property (nonatomic,copy) NSString *password;


/**
 按钮可以被点击
 */
@property (nonatomic,assign) BOOL buttonEnable;

- (void)loginWithSuccess:(void(^)(void))success failture:(void(^)(NSString *msg))failture;

@end

.m文件的实现

//通过宏定义来模拟正确的用户名和密码
#define correctName @"aaaaaa"
#define correctassword @"aaaaaa"

#import "LoginViewModel.h"
#import <ReactiveCocoa.h>
@implementation LoginViewModel

- (instancetype)init {
    if (self = [super init]) {
        
        @weakify(self);
        [[RACObserve(self, name) filter:^BOOL(id value) {
            return value != nil;
        }] subscribeNext:^(NSString *value) {
            @strongify(self);
            //利用正则去除特殊字符
            value = [value stringByReplacingOccurrencesOfString:@"[^0-9a-zA-Z]" withString:@"" options:NSRegularExpressionSearch range:NSMakeRange(0, value.length)];
            //用循环保证粘贴复制进入的字符串也会符合首位字符必须是字母的限制
            while (value.length > 0) {
                char c = [value characterAtIndex:0];
                
                if (c < '0' || c > '9') break;
                
                value = [value substringFromIndex:1];
            }
            //因为最终赋值的时候会重复调用Block,所以需要通过判断过滤防止函数死锁
            if (![value isEqualToString:self.name]) self.name = value;
        }];
        //combineLatest:标识将两个信号结合起来,任何一个发生sendNext的时候均会出发新的信号的sendNext
        //reduce:Block中表示可以自定义将结合后的信号组合新的返回值
        RAC(self,buttonEnable) = [RACSignal combineLatest:@[RACObserve(self, name),RACObserve(self, password)] reduce:^id(NSString *name,NSString *password){
            return @(name.length > 5&& password.length > 5);
        }];
    }
    return self;
}
/**
 登录成功失败通过回调block将状态传递
 */
- (void)loginWithSuccess:(void (^)(void))success failture:(void (^)(NSString *))failture {
    BOOL correct = [self.name isEqualToString:correctName] && [self.password isEqualToString:correctassword];
    
    if (correct) {
        success();
    }else {
        failture(@"用户名或密码错误");
    }
}
@end

ViewController.m的调用 使用storyBoard来完成页面相关布局

#import "ViewController.h"
#import "LoginViewModel.h"
#import <ReactiveCocoa.h>

#import "HomePageViewController.h"
@interface ViewController ()
@property (weak, nonatomic) IBOutlet UITextField *userNameTextField;
@property (weak, nonatomic) IBOutlet UITextField *passwordTextField;
@property (weak, nonatomic) IBOutlet UIButton *loginButton;

@property (nonatomic,strong) LoginViewModel *viewModel;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    _viewModel = [[LoginViewModel alloc] init];
    
    [self.loginButton setTitleColor:[UIColor grayColor] forState:(UIControlStateDisabled)];
    [self.loginButton setTitleColor:[UIColor cyanColor] forState:(UIControlStateNormal)];
    
    [self addBind];
    @weakify(self);
    //button的点击事件用RAC调用
    [[self.loginButton rac_signalForControlEvents:(UIControlEventTouchUpInside)] subscribeNext:^(id x) {
        @strongify(self);
        [self.viewModel loginWithSuccess:^{
            [self.navigationController pushViewController:[HomePageViewController new] animated:YES];
        } failture:^(NSString *msg) {
            UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"登录失败" message:msg preferredStyle:(UIAlertControllerStyleAlert)];
            
            [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:(UIAlertActionStyleDefault) handler:^(UIAlertAction * _Nonnull action) {
                
            }]];
            
            [self presentViewController:alert animated:YES completion:nil];
        }];
    }];
}

- (void)addBind {
    //将用户名输入框和viewModel中的name字段进行绑定,正向绑定(viewModel中的name字段发生变化时,保证textField中的显示也会变化)
    //RAC(,) 是一个赋值的宏定义,其意义就相当于当一个RACSignal 被发送sendNext的时候,将其中的值赋值给定义的定义后的变量
    //RACObserve(,)
    RAC(_userNameTextField,text) = RACObserve(_viewModel, name);
    //此处加正向绑定,使TextField的值传递能即时传递到viewModel中
    RAC(_viewModel,name) = _userNameTextField.rac_textSignal;
    
    RAC(_passwordTextField,text) = RACObserve(_viewModel, password);
    RAC(_viewModel,password) = _passwordTextField.rac_textSignal;
    //将按钮的可用与ViewModel的按钮可用状态进行绑定
    RAC(_loginButton,enabled) = RACObserve(_viewModel, buttonEnable);
}
- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}
@end
最终实现效果

现在登录完成了,接下来要做的就是完成登录后的页面了
首先先完成Model

#import <Foundation/Foundation.h>

/**
 这个model模拟的是一个tableViewCell需要的Model
 */
@interface HomePageModel : NSObject

/**
 排名
 */
@property (nonatomic,copy) NSString *number;

/**
 姓名
 */
@property (nonatomic,copy) NSString *name;

/**
 分数
 */
@property (nonatomic,copy) NSString *score;

/**
 总人数,这个字段模拟网络请求,将会延时返回
 */
@property (nonatomic,copy) NSString *totalNumber;

@end

随后完成ViewModel
HomeViewModel.h

#import <Foundation/Foundation.h>

@interface HomePageViewModel : NSObject
//为数据显示数组
@property (nonatomic,strong) NSArray *dataArray;
//为VM获取数据接口
- (void)getDatas;

@end

HomeViewModel.m

#import "HomePageViewModel.h"
#import "HomePageModel.h"

@interface HomePageViewModel ()

@property (nonatomic,strong) NSTimer *timer;

@end

@implementation HomePageViewModel
- (void)setTimer:(NSTimer *)timer {
    if (!_timer) {
        [_timer invalidate];
    }
    _timer = timer;
}

//模拟网络通过延时加载数据
- (void)getDatas {
    NSMutableArray *array = [NSMutableArray array];
    
    for (int i = 0; i < 40; i ++) {
        [array addObject:({
            HomePageModel *model = [[HomePageModel alloc] init];
            
            model.name = @"小明";
            
            model.number = @"10";
            
            model.score = @"92";
            
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                model.totalNumber = [NSString stringWithFormat:@"%d",i + 10];
            });
            
            model;
        })];
    }
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.dataArray = array;
    });
    //通过timer模拟需要定时刷新的数据
    if (@available(iOS 10.0, *)) {
        _timer = [NSTimer scheduledTimerWithTimeInterval:2 repeats:YES block:^(NSTimer * _Nonnull timer) {
            [self timerAction];
        }];
    } else {
        // Fallback on earlier versions
    }
}

- (void)timerAction {
    for (HomePageModel *model in self.dataArray) {
        model.totalNumber = [NSString stringWithFormat:@"%ld",model.totalNumber.integerValue + 1];
    }
}

- (void)dealloc {
    if (_timer) {
        [_timer invalidate];
    }
}

@end

先完成一个TableViewCell
HomePageTableViewCell.h

#import <UIKit/UIKit.h>

#import "HomePageModel.h"

@interface HomePageTableViewCell : UITableViewCell

@property(nonatomic,weak) HomePageModel *model;

@end

HomePageTableViewCell.m

#import "HomePageTableViewCell.h"
#import <ReactiveCocoa.h>
@interface HomePageTableViewCell ()
@property (weak, nonatomic) IBOutlet UILabel *numberLabel;
@property (weak, nonatomic) IBOutlet UILabel *nameLabel;
@property (weak, nonatomic) IBOutlet UILabel *scoreLabel;
@property (weak, nonatomic) IBOutlet UILabel *totalNumberLabel;

@property (nonatomic,assign) NSInteger count;

@end

@implementation HomePageTableViewCell
static NSInteger i = 0;
- (void)awakeFromNib {
    [super awakeFromNib];
    _count = i;
    i ++;
}
- (void)setModel:(HomePageModel *)model {
    _model = model;
    
    _numberLabel.text = model.number;
    _nameLabel.text = model.name;
    _scoreLabel.text = model.score;
    @weakify(self);
    
    
    //异步将数据更新,同时在tableViewCell的model更换后及时的将之前的观察置空,防止cell被重用后,原有Model数据更新后产生数据错乱
    //takeUntil:代表着这个信号直到另一个信号发出的时候会被释放
    [[RACObserve(model, totalNumber) takeUntil:[self rac_valuesAndChangesForKeyPath:@keypath(self, model) options:(NSKeyValueObservingOptionNew) observer:self]] subscribeNext:^(id x) {
        @strongify(self);
        
        self.totalNumberLabel.text = x;
    }];
}


- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
    [super setSelected:selected animated:animated];

    // Configure the view for the selected state
}

@end

最后一步,就是HomePageViewController的实现了

#import "HomePageViewController.h"
#import "HomePageViewModel.h"
#import "HomePageTableViewCell.h"
#import <ReactiveCocoa.h>
@interface HomePageViewController ()<UITableViewDelegate,UITableViewDataSource>
@property (weak, nonatomic) IBOutlet UITableView *tableView;

@property (nonatomic,copy) HomePageViewModel *viewModel;

@end

@implementation HomePageViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    _viewModel = [[HomePageViewModel alloc] init];
    
    _tableView.delegate = self;
    _tableView.dataSource = self;
    [_tableView registerNib:[UINib nibWithNibName:@"HomePageTableViewCell" bundle:nil] forCellReuseIdentifier:@"cellId"];
    [self addBind];
    
    [_viewModel getDatas];
}

- (void)addBind {
    @weakify(self);
    [RACObserve(_viewModel, dataArray) subscribeNext:^(id x) {
        @strongify(self);
        [self.tableView reloadData];
    }];
}

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.viewModel.dataArray.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    HomePageTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cellId" forIndexPath:indexPath];
    
    cell.model = self.viewModel.dataArray[indexPath.row];
    
    return cell;
}


@end

最终显示结果

这个就是代码的最终运行结果了
下面是demo的github地址
MVVM模式和ReactiveCocoa 框架的契合度十分之高,基于Block的回调机制用起来也很舒服,同时对于代码解耦也能发挥一定作用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,585评论 4 365
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,923评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,314评论 0 248
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,346评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,718评论 3 291
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,828评论 1 223
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,020评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,758评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,486评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,722评论 2 251
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,196评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,546评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,211评论 3 240
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,132评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,916评论 0 200
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,904评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,758评论 2 274

推荐阅读更多精彩内容