尝试新的架构当然不会从老的项目中直接修改,这样风险太大,所以我决定单独写一个小的Demo,这个本来是封装好的网络库,Demo就直接放到这里面了。用小Demo来尝试新的架构设计,然后再在原工程上逐渐引入新的架构。
Demo是实现一个豆瓣用户的搜索功能,Demo的最终效果图是这样的:
Demo的完整架构如下:
橙色表示操作、绿色虚线表示数据的流动
1. 定义Model
根据豆瓣API里关于User的定义,定义了这边的Model
@interface UserItemModel : MTLModel <MTLJSONSerializing>
@property (nonatomic, copy) NSString *locId;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *createdTime;
@property (nonatomic, copy) NSString *locName;
@property (nonatomic, copy) NSString *avatarURLString;
@property (nonatomic, copy) NSString *signature;
@property (nonatomic, copy) NSString *uid;
@property (nonatomic, assign) BOOL isBanned;
@property (nonatomic, assign) BOOL isSuicide;
@end
项目引入了Mantle,方便从JSON到Model的转化。
2. 定义ViewModel
这里需要定义两个ViewModel,第一个UserListViewModel
是为我们的Controller提供网络访问、列表数据管理等,第二个UserItemViewModel
是用来配置Cell的,每个Cell绑定一个userItemViewModel。
2.1 UserListViewModel
UserListViewModel公开给Controller的内容很少,如下:
// UserListViewModel.h @interface UserListViewModel : NSObject<YLNetworkingListRACProtocol> @property (nonatomic, copy) NSString *keywords; @property (nonatomic, readonly) BOOL hasNextPage; @property (nonatomic, copy) NSArray<UserItemViewModel *> *userItemViewModels; @end
- keywords即是要搜索的关键字,需要由Controller告诉ViewModel。
- hasNextPage是告诉Controller什么时候标记已无数据
- userItemViewModels即tableView的数据源
- 实现YLNetworkingListRACProtocol协议,是将对网络请求的RAC封装开放出去而避免直接对外公开APIManager,这点在第一篇文章中已经解释过了。
由于把关于网络请求部分的RAC操作也单独的抽离出去,所以ViewModel显得更简洁一些,
// UserListViewModel.m #import "UserListViewModel.h" #import "UserAPIManager.h" @interface UserListViewModel()<YLAPIManagerDataSource> @property (nonatomic, strong) UserAPIManager *userAPIManager; @end @implementation UserListViewModel - (instancetype)init { self = [super init]; if (self) { [self setupRAC]; } return self; } - (void)setupRAC { @weakify(self); [self.networkingRAC.executionSignal subscribeNext:^(id x) { @strongify(self); if ([x boolValue]) { self.userItemViewModels = nil; } NSMutableArray *userItemViewModels = [NSMutableArray arrayWithArray:self.userItemViewModels]; NSArray *userModels = [self.userAPIManager fetchDataFromModel]; RACSequence *userViewModelSeq = [userModels.rac_sequence map:^id(UserItemModel *model) { return [[UserItemViewModel alloc] initWithModel:model]; }]; [userItemViewModels addObjectsFromArray:userViewModelSeq.array]; self.userItemViewModels = userItemViewModels; }]; } - (NSDictionary *)paramsForAPI:(YLBaseAPIManager *)manager { NSDictionary *params = @{}; if (manager == self.userAPIManager) { params = @{ kUserAPIManagerParamsKeySearchKeywords:self.keywords?:@"", }; } return params; } #pragma mark - getter && setter - (UserAPIManager *)userAPIManager { if (_userAPIManager == nil) { _userAPIManager = [[UserAPIManager alloc] initWithPageSize:20]; _userAPIManager.dataSource = self; } return _userAPIManager; } - (BOOL)hasNextPage { return self.userAPIManager.hasNextPage; } - (id<YLNetworkingListRACOperationProtocol>)networkingRAC { return self.userAPIManager; } @end
重点在setupRAC
方法,在这里订阅了Command执行完成的信号(注意我在NetworkingRAC封装的时候直接将executionSignals进行了switchToLatest),所以这里得到的就是Command执行完成的信号,首先判定传进来的参数的bool值(实在NetworkingRAC封装时进行的map),来确定是否需要重置数据列表。这里直接通过[self.userAPIManager fetchDataFromModel]
来获取请求得到的models,然后将其转换为userItemViewModels,最后进行赋值,此处的赋值会引发ViewController的刷新,后面讲ViewController的时候在来具体说明。
请求的参数是由paramsForAPI
提供出去的。
2.2 UserItemViewModel
这个ViewModel就比较简单,直接简单的将model转变为ViewModel,这里只保留了当前需要的几个参数
// UserItemViewModel.h @interface UserItemViewModel : NSObject @property (nonatomic, copy) NSString *name; @property (nonatomic, copy) NSString *locName; @property (nonatomic, copy) NSString *signature; @property (nonatomic, copy) NSURL *avatarURL; - (instancetype)initWithModel:(UserItemModel *)model; @end
initWithModel
定义如下
// UserItemViewModel.m - (instancetype)initWithModel:(UserItemModel *)model { self = [super init]; if (self) { RAC(self, name) = RACObserve(model, name); RAC(self, locName) = RACObserve(model, locName); RAC(self, signature) = RACObserve(model, signature); RAC(self, avatarURL) = [RACObserve(model, avatarURLString) map:^id(NSString *value) { if (value == nil) { return nil; } return [[NSURL alloc] initWithString:value]; }]; } return self; }
其实这里完全可以不用RAC,由于这里的单个model并不会发生改变,所以这里直接进行赋值也是可以的,但是当model有可能会发生变化时,还是采用RAC绑定比较好。就比如说我们通常会针对App登陆用户建立一个单例的User类,我们在展示这个User的时候建立起的ViewModel就使用RAC绑定就很方便了,一旦用户改变了User的一些信息,ViewModel将自动更新数据并进一步引发View层的视图更新。
3. TableViewCell
在看Controller之前,我们先看一下Cell的定义,Cell布局比较简单,头像+昵称+地点+签名。我这里使用了xib,那么就直接在awakeFromNib
里进行数据绑定。
- (void)awakeFromNib { self.avatarImageView.image = nil; //prevent dirty data @weakify(self); [RACObserve(self, viewModel) subscribeNext:^(UserItemViewModel *viewModel) { @strongify(self); self.nameLabel.text = viewModel.name; self.localeLabel.text = viewModel.locName; self.signatureLabel.text = viewModel.signature; [self.avatarImageView setImageWithURL:viewModel.avatarURL]; }]; }
在这里,当Cell的ViewModel发生变化的时候,View会立即更新数据,注意这里是针对整个viewModel发生赋值变化替换成另外一个viewModel,而没有分别检测viewModel的每一项,因为通常针对Cell的赋值,是整个通过viewModel进行配置。
这里有一个小小的违背MVVM模式的一点,就是设置头像,我在view里直接调用了AFNetworking的一个扩展发起网络访问。但是我觉得这样做也是合理的,毕竟模式也只是用来参考,并不绝对,过度的封装会导致代码可读性更差,反倒得不偿失。前面将image置为nil是为了防止重用Cell时脏数据造成的影响。
这里RAC起到的作用是对应的是第一篇文章中作用于View和ViewModel之间
,当ViewModel变化时,变化的信号传递到View这里,然后View针对新的数据进行更新。
4. ViewController
做了那么多准备工作,终于到Controller了,ViewModel将网络访问、数据处理承接之后,Cotroller就变得很轻,只剩下数据的绑定工作和跳转、展示等逻辑处理。
4.1 数据绑定与错误处理
@weakify(self); RAC(self.viewModel,keywords) = self.searchTextField.rac_textSignal; [[[RACObserve(self.viewModel, userItemViewModels) skip:1] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(id x) { @strongify(self); [self.tableView reloadData]; [self.tableView.header endRefreshing]; if (self.viewModel.hasNextPage) { [self.tableView.footer endRefreshing]; } else { [self.tableView.footer endRefreshingWithNoMoreData]; } }]; [self.viewModel.networkingRAC.requestErrorSignal subscribeNext:^(NSError *error) { @strongify(self); self.title = error.localizedDescription; [self.tableView.header endRefreshing]; [self.tableView.footer endRefreshing]; }];
首先,我们先将textField的内容与viewModel的keywords进行绑定,这样当textField发生改变时,viewModel的keywords也随之改变,下次搜索请求发出去的时候的参数也就自然更新成用户最新输入的内容了。
这里进行观测viewModel的userItemViewModels,如果其发生了赋值改变,则进行reloadData,并关闭header和footer的刷新状态(这里下拉刷新和上拉加载更多采用MJRefresh),注意在更新UI之前需要先切换到主线程,RAC提供给了一个方便的方式deliverOn:[RACScheduler mainThreadScheduler]
。绑定工作放在viewDidLoad
里即可。
网络错误的信号是之前封装好的了,拿来直接用即可,封装的时候已经切换到主线程了,所以这里就不需要再管线程的事情了。
4.2 请求逻辑
我这里采用了xib,所以配置tableView单独写了一个方法,viewDidLoad中进行调用,如果使用代码初始化的话,直接把这些配置内容放到getter里就好了。
- (void)setupTableView { UINib *userNib = [UINib nibWithNibName:@"UserItemViewCell" bundle:nil]; [self.tableView registerNib:userNib forCellReuseIdentifier:UserItemViewCellIdentifier]; @weakify(self); self.tableView.header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{ @strongify(self); [self.networkingRAC.refreshingCommand execute:nil]; }]; self.tableView.footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{ @strongify(self); [self.networkingRAC.requestNextPageCommand execute:nil]; }]; }
我们将网络请求的RAC操作进行了抽离,viewModel也实现了YLNetworkingListRACProtocol协议,所以这里调起网络请求就是这个样子[self.networkingRAC.refreshingCommand execute:nil];
,Controller是无权直接使用APIManager的,防止逻辑发生混乱,职责分明。
另外我们也需要在viewDidLoad
里,将Button的点击事件与搜索关联起来
self.operationButton.rac_command = self.viewModel.networkingRAC.refreshCommand;
MJRefresh本身是不支持RACCommand的,它只支持Target-Action或者Block,每次都要写成上面那么多行,而且每次都一样。算了,自己动手稍微封装一下,用起来方便一些。
// MJRefresh+ReactiveExtension.h #import "MJRefresh.h" #import <ReactiveCocoa/ReactiveCocoa.h> @interface MJRefreshHeader (ReactiveExtension) + (instancetype)headerWithRefreshingCommand:(RACCommand *)refreshingCommand; @end @interface MJRefreshFooter(ReactiveExtension) + (instancetype)footerWithRefreshingCommand:(RACCommand *)refreshingCommand; @end
// MJRefresh+ReactiveExtension.m #import "MJRefresh+ReactiveExtension.h" @implementation MJRefreshHeader (ReactiveExtension) + (instancetype)headerWithRefreshingCommand:(RACCommand *)refreshingCommand { @weakify(refreshingCommand); return [self headerWithRefreshingBlock:^{ @strongify(refreshingCommand); [refreshingCommand execute:nil]; }]; } @end @implementation MJRefreshFooter(ReactiveExtension) + (instancetype)footerWithRefreshingCommand:(RACCommand *)refreshingCommand { @weakify(refreshingCommand); return [self footerWithRefreshingBlock:^{ @strongify(refreshingCommand); [refreshingCommand execute:nil]; }]; } @end
这样封装一来,setupTableView
就变成了这个样子,
- (void)setupTableView { UINib *userNib = [UINib nibWithNibName:@"UserItemViewCell" bundle:nil]; [self.tableView registerNib:userNib forCellReuseIdentifier:UserItemViewCellIdentifier]; self.tableView.header = [MJRefreshNormalHeader headerWithRefreshingCommand:self.viewModel.networkingRAC.refreshCommand]; self.tableView.footer = [MJRefreshAutoNormalFooter footerWithRefreshingCommand:self.viewModel.networkingRAC.requestNextPageCommand]; }
嗯,这下子看起来舒服多了。
4.3 Delegate && DataSource
由于viewModel把数据处理都抢走了,所以这里的逻辑被削减的尤为简单。
#pragma mark - UITableViewDelegate && UITableViewDataSource - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UserItemViewCell *cell = [tableView dequeueReusableCellWithIdentifier:UserItemViewCellIdentifier forIndexPath:indexPath]; cell.viewModel = self.viewModel.userItemViewModels[indexPath.row]; return cell; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return [self.viewModel.userItemViewModels count]; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath:indexPath animated:YES]; // 跳转页面逻辑 } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return UserItemViewCellHeight; }
至此,实践的Demo到此结束,完整的Demo在这里。
MVVM让各个模块权责分明,很容易理解,对于稍微复杂的项目明显更易于维护一些。但是RAC就显得不那么直白,刚学RAC会看起来很复杂,需要区分热信号和冷信号,而且还经常需要考虑带来的副作用。
RAC坑很深,MVVM的路还远,任重而道远!