iOS-MVVM模式
公司最近开始新的app项目,一直都在看mvvm,所以准备开发一个mvvm模式的app。
什么是MVVM?
mvvm属于设计模式的一种,Model-View-ViewModel的简写。是属于对mvc架构的一种优化,用于抽离mvc中过于复杂的Controller。
mvvm应用响应式编程的理念,采用绑定的方法将ViewModel中的值与对应的View或者Controller中相关控件进行关联。最终目的是将页面或网络数据集合在一个实体类里,增加中间层,使每个类的目标更加明确。
在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的回调机制用起来也很舒服,同时对于代码解耦也能发挥一定作用。