杂谈: MVC/MVP/MVVM (二)

MVP

MVC的缺点在于并没有区分业务逻辑和业务展示, 这对单元测试很不友好. MVP针对以上缺点做了优化, 它将业务逻辑和业务展示也做了一层隔离, 对应的就变成了MVCP. M和V功能不变, 原来的C现在只负责布局, 而所有的逻辑全都转移到了P层.

对应关系如图所示:


业务场景没有变化, 依然是展示三种数据, 只是三个MVC替换成了三个MVP(图中我只画了Blog模块), UserVC负责配置三个MVP(新建各自的VP, 通过VP建立C, C会负责建立VP之间的绑定关系), 并在合适的时机通知各自的P层(之前是通知C层)进行数据获取, 各个P层在获取到数据后进行相应处理, 处理完成后会通知绑定的View数据有所更新, V收到更新通知后从P获取格式化好的数据进行页面渲染, UserVC最后将已经渲染好的各个View进行布局即可. 另外, V层C层不再处理任何业务逻辑, 所有事件触发全部调用P层的相应命令, 具体到代码中如下:

@interface BlogPresenter : NSObject

+ (instancetype)instanceWithUserId:(NSUInteger)userId;

- (NSArray *)allDatas;//业务逻辑移到了P层 和业务相关的M也跟着到了P层

- (void)refreshUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

- (void)loadMoreUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

@end

@interface BlogPresenter()

@property (assign, nonatomic) NSUInteger userId;

@property (strong, nonatomic) NSMutableArray *blogs;

@property (strong, nonatomic) UserAPIManager *apiManager;

@end

@implementation BlogPresenter

+ (instancetype)instanceWithUserId:(NSUInteger)userId {

return [[BlogPresenter alloc] initWithUserId:userId];

}

- (instancetype)initWithUserId:(NSUInteger)userId {

if (self = [super init]) {

self.userId = userId;

self.apiManager = [UserAPIManager new];

//...略

}

}

#pragma mark - Interface

- (NSArray *)allDatas {

return self.blogs;

}

//提供给外层调用的命令

- (void)refreshUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler {

[self.apiManager refreshUserBlogsWithUserId:self.userId completionHandler:^(NSError *error, id result) {

if (!error) {

[self.blogs removeAllObjects];//清空之前的数据

for (Blog *blog in result) {

[self.blogs addObject:[BlogCellPresenter presenterWithBlog:blog]];

}

}

completionHandler ? completionHandler(error, result) : nil;

}];

}

//提供给外层调用的命令

- (void)loadMoreUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler {

[self.apiManager loadMoreUserBlogsWithUserId:self.userId completionHandler...]

}

@end

@interface BlogCellPresenter : NSObject

+ (instancetype)presenterWithBlog:(Blog *)blog;

- (NSString *)authorText;

- (NSString *)likeCountText;

- (void)likeBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

- (void)shareBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;

@end

@implementation BlogCellPresenter

- (NSString *)likeCountText {

return [NSString stringWithFormat:@"赞 %ld", self.blog.likeCount];

}

- (NSString *)authorText {

return [NSString stringWithFormat:@"作者姓名: %@", self.blog.authorName];

}

//    ...略

- (void)likeBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler {

[[UserAPIManager new] likeBlogWithBlogId:self.blogId userId:self.userId completionHandler:^(NSError *error, id result) {

if (error) {

//do fail

} else {

//do success

self.blog.likeCount += 1;

}

completionHandler ? completionHandler(error, result) : nil;

}];

}

//    ...略

@end

BlogPresenter和BlogCellPresenter分别作为BlogViewController和BlogCell的P层, 其实就是一系列业务逻辑的集合. BlogPresenter负责获取Blogs原始数据并通过这些原始数据构造BlogCellPresenter, 而BlogCellPresenter提供格式化好的各种数据以供Cell渲染, 另外, 点赞和分享的业务现在也转移到了这里.

业务逻辑被转移到了P层, 此时的V层只需要做两件事:

1.监听P层的数据更新通知, 刷新页面展示.

2.在点击事件触发时, 调用P层的对应方法, 并对方法执行结果进行展示.

@interface BlogCell : UITableViewCell

@property (strong, nonatomic) BlogCellPresenter *presenter;

@end

@implementation BlogCell

- (void)setPresenter:(BlogCellPresenter *)presenter {

_presenter = presenter;

//从Presenter获取格式化好的数据进行展示

self.authorLabel.text = presenter.authorText;

self.likeCountLebel.text = presenter.likeCountText;

//    ...略

}

#pragma mark - Action

- (void)onClickLikeButton:(UIButton *)sender {

[self.presenter likeBlogWithCompletionHandler:^(NSError *error, id result) {

if (!error) {//页面刷新

self.likeCountLebel.text = self.presenter.likeCountText;

}

//        ...略

}];

}

@end

而C层做的事情就是布局和PV之间的绑定(这里可能不太明显, 因为BlogVC里面的布局代码是TableViewDataSource, PV绑定的话, 因为我偷懒用了Block做通知回调, 所以也不太明显, 如果是Protocol回调就很明显了), 代码如下:

@interface BlogViewController : NSObject

+ (instancetype)instanceWithTableView:(UITableView *)tableView presenter:(BlogPresenter)presenter;

- (void)setDidSelectRowHandler:(void (^)(Blog *))didSelectRowHandler;

- (void)fetchDataWithCompletionHandler:(NetworkCompletionHandler)completionHandler;

@end


BlogViewController现在不再负责实际的数据获取逻辑, 数据获取直接调用Presenter的相应接口, 另外, 因为业务逻辑也转移到了Presenter, 所以TableView的布局用的也是Presenter.allDatas. 至于Cell的展示, 我们替换了原来大量的Set方法, 让Cell自己根据绑定的CellPresenter做展示. 毕竟现在逻辑都移到了P层, V层要做相应的交互也必须依赖对应的P层命令, 好在V和M仍然是隔离的, 只是和P耦合了, P层是可以随意替换的, M显然不行, 这是一种折中.

最后是Scene, 它的变动不大, 只是替换配置MVC为配置MVP, 另外数据获取也是走P层, 不走C层了(然而代码里面并不是这样的):

- (void)configuration {

//    ...其他设置

BlogPresenter *blogPresenter = [BlogPresenter instanceWithUserId:self.userId];

self.blogViewController = [BlogViewController instanceWithTableView:self.blogTableView presenter:blogPresenter];

[self.blogViewController setDidSelectRowHandler:^(Blog *blog) {

[self.navigationController pushViewController:[BlogDetailViewController instanceWithBlog:blog] animated:YES];

}];

//    ...略

}

- (void)fetchData {

//        ...略

[self.userInfoVC fetchData];

[HUD show];

[self.blogViewController fetchDataWithCompletionHandler:^(NSError *error, id result) {

[HUD hide];

}];

//还是因为懒, 用了Block走C层转发会少写一些代码, 如果是Protocol或者KVO方式就会用self.blogViewController.presenter了

//不过没有关系, 因为我们替换MVC为MVP是为了解决单元测试的问题, 现在的用法完全不影响单元测试, 只是和概念不符罢了.

//        ...略

}

上面的例子中其实有一个问题, 即我们假定: 所有的事件都是由V层主动发起且一次性的. 这其实是不成立的, 举个简单的例子: 类似微信语音聊天之类的页面, 点击语音Cell开始播放, Cell展示播放动画, 播放完成动画停止, 然后播放下一条语音.

在这个播放场景中, 如果CellPresenter还是像上面一样仅仅提供一个playWithCompletionHandler的接口是行不通的. 因为播放完成后回调肯定是在C层, C层在播放完成后会发现此时执行播放命令的CellPresenter无法通知Cell停止动画, 即事件的触发不是一次性的. 另外, 在播放完成后, C层遍历到下一个待播放CellPresenterX调用播放接口时, CellPresenterX因为并不知道它对应的Cell是谁, 当然也就无法通知Cell开始动画, 即事件的发起者并不一定是V层.

针对这些非一次性或者其他层发起事件, 处理方法其实很简单, 在CellPresenter加个Block属性就行了, 因为是属性, Block可以多次回调, 另外Block还可以捕获Cell, 所以也不担心找不到对应的Cell. 大概这样:

@interface VoiceCellPresenter : NSObject

@property (copy, nonatomic) void(^didUpdatePlayStateHandler)(NSUInteger);

- (NSURL *)playURL;

@end

@implementation VoiceCell

- (void)setPresenter:(VoiceCellPresenter *)presenter {

_presenter = presenter;

if (!presenter.didUpdatePlayStateHandler) {

__weak typeof(self) weakSelf = self;

[presenter setDidUpdatePlayStateHandler:^(NSUInteger playState) {

switch (playState) {

case Buffering: weakSelf.playButton... break;

case Playing: weakSelf.playButton... break;

case Paused: weakSelf.playButton... break;

}

}];

}

}

播放的时候, VC只需要保持一下CellPresenter, 然后传入相应的playState调用didUpdatePlayStateHandler就可以更新Cell的状态了.

当然, 如果是Protocol的方式进行的VP绑定, 那么做这些事情就很平常了, 就不写了.

MVP大概就是这个样子了, 相对于MVC, 它其实只做了一件事情, 即分割业务展示和业务逻辑. 展示和逻辑分开后, 只要我们能保证V在收到P的数据更新通知后能正常刷新页面, 那么整个业务就没有问题. 因为V收到的通知其实都是来自于P层的数据获取/更新操作, 所以我们只要保证P层的这些操作都是正常的就可以了. 即我们只用测试P层的逻辑, 不必关心V层的情况.

MVVM

MVP其实已经是一个很好的架构, 几乎解决了所有已知的问题, 那么为什么还会有MVVM呢?

仍然是举例说明, 假设现在有一个Cell, 点击Cell上面的关注按钮可以是加关注, 也可以是取消关注, 在取消关注时, SceneA要求先弹窗询问, 而SceneB则不做弹窗, 那么此时的取消关注操作就和业务场景强关联, 所以这个接口不可能是V层直接调用, 会上升到Scene层.具体到代码中, 大概这个样子:

@interface UserCellPresenter : NSObject

@property (copy, nonatomic) void(^followStateHander)(BOOL isFollowing);

@property (assign, nonatomic) BOOL isFollowing;

- (void)follow;

@end

@implementation UserCellPresenter

- (void)follow {

if (!self.isFollowing) {//未关注 去关注

//        follow user

} else {//已关注 则取消关注

self.followStateHander ? self.followStateHander(YES) : nil;//先通知Cell显示follow状态

[[FollowAPIManager new] unfollowWithUserId:self.userId completionHandler:^(NSError *error, id result) {

if (error) {

self.followStateHander ? self.followStateHander(NO) : nil;//follow失败 状态回退

} eles {

self.isFollowing = YES;

}

//...略

}];

}

}

@end

@implementation UserCell

- (void)setPresenter:(UserCellPresenter *)presenter {

_presenter = presenter;

if (!_presenter.followStateHander) {

__weak typeof(self) weakSelf = self;

[_presenter setFollowStateHander:^(BOOL isFollowing) {

[weakSelf.followStateButton setImage:isFollowing ? : ...];

}];

}

}

- (void)onClickFollowButton:(UIButton *)button {//将关注按钮点击事件上传

[self routeEvent:@"followEvent" userInfo:@{@"presenter" : self.presenter}];

}

@end

@implementation FollowListViewController

//拦截点击事件 判断后确认是否执行事件

- (void)routeEvent:(NSString *)eventName userInfo:(NSDictionary *)userInfo {

if ([eventName isEqualToString:@"followEvent"]) {

UserCellPresenter *presenter = userInfo[@"presenter"];

[self showAlertWithTitle:@"提示" message:@"确认取消对他的关注吗?" cancelHandler:nil confirmHandler: ^{

[presenter follow];

}];

}

}

@end

@implementation UIResponder (Router)

//沿着响应者链将事件上传 事件最终被拦截处理 或者 无人处理直接丢弃

- (void)routeEvent:(NSString *)eventName userInfo:(NSDictionary *)userInfo {

[self.nextResponder routeEvent:eventName userInfo:userInfo];

}

@end

Block方式看起来略显繁琐, 我们换到Protocol看看:

@protocol UserCellPresenterCallBack

- (void)userCellPresenterDidUpdateFollowState:(BOOL)isFollowing;

@end

@interface UserCellPresenter : NSObject

@property (weak, nonatomic) id view;

@property (assign, nonatomic) BOOL isFollowing;

- (void)follow;

@end

@implementation UserCellPresenter

- (void)follow {

if (!self.isFollowing) {//未关注 去关注

//        follow user

} else {//已关注 则取消关注

BOOL isResponse = [self.view respondsToSelector:@selector(userCellPresenterDidUpdateFollowState)];

isResponse ? [self.view userCellPresenterDidUpdateFollowState:YES] : nil;

[[FollowAPIManager new] unfollowWithUserId:self.userId completionHandler:^(NSError *error, id result) {

if (error) {

isResponse ? [self.view userCellPresenterDidUpdateFollowState:NO] : nil;

} eles {

self.isFollowing = YES;

}

//...略

}];

}

}

@end

@implementation UserCell

- (void)setPresenter:(UserCellPresenter *)presenter {

_presenter = presenter;

_presenter.view = self;

}

#pragma mark - UserCellPresenterCallBack

- (void)userCellPresenterDidUpdateFollowState:(BOOL)isFollowing {

[self.followStateButton setImage:isFollowing ? : ...];

}

除去Route和VC中Alert之类的代码, 可以发现无论是Block方式还是Protocol方式因为需要对页面展示和业务逻辑进行隔离, 代码上饶了一小圈, 无形中增添了不少的代码量, 这里仅仅只是一个事件就这样, 如果是多个呢? 那写起来真是蛮伤的…

仔细看一下上面的代码就会发现, 如果我们继续添加事件, 那么大部分的代码都是在做一件事情: P层将数据更新通知到V层. Block方式会在P层添加很多属性, 在V层添加很多设置Block逻辑. 而Protocol方式虽然P层只添加了一个属性, 但是Protocol里面的方法却会一直增加, 对应的V层也就需要增加的方法实现.

问题既然找到了, 那就试着去解决一下吧, OC中能够实现两个对象间的低耦合通信, 除了Block和Protocol, 一般都会想到KVO. 我们看看KVO在上面的例子有何表现:

@interface UserCellViewModel : NSObject

@property (assign, nonatomic) BOOL isFollowing;

- (void)follow;

@end

@implementation UserCellViewModel

- (void)follow {

if (!self.isFollowing) {//未关注 去关注

//        follow user

} else {//已关注 则取消关注

self.isFollowing = YES;//先通知Cell显示follow状态

[[FollowAPIManager new] unfollowWithUserId:self.userId completionHandler:^(NSError *error, id result) {

if (error) { self.isFollowing = NO; }//follow失败 状态回退

//...略

}];

}

}

@end

@implementation UserCell

- (void)awakeFromNib {

@weakify(self);

[RACObserve(self, viewModel.isFollowing) subscribeNext:^(NSNumber *isFollowing) {

@strongify(self);

[self.followStateButton setImage:[isFollowing boolValue] ? : ...];

};

}

代码大概少了一半左右, 另外, 逻辑读起来也清晰多了, Cell观察绑定的ViewModel的isFollowing状态, 并在状态改变时, 更新自己的展示.

三种数据通知方式简单一比对, 相信哪种方式对程序员更加友好, 大家都心里有数, 就不做赘述了.

现在大概一提到MVVM就会想到RAC, 但这两者其实并没有什么联系, 对于MVVM而言RAC只是提供了优雅安全的数据绑定方式, 如果不想学RAC, 自己搞个KVOHelper之类的东西也是可以的. 另外 ,RAC的魅力其实在于函数式响应式编程, 我们不应该仅仅将它局限于MVVM的应用, 日常的开发中也应该多使用使用的.

关于MVVM, 我想说的就是这么多了, 因为MVVM其实只是MVP的绑定进化体, 除去数据绑定方式, 其他的和MVP如出一辙, 只是可能呈现方式是Command/Signal而不是CompletionHandler之类的, 故不做赘述.

最后做个简单的总结吧:

1.MVC作为老牌架构, 优点在于将业务场景按展示数据类型划分出多个模块, 每个模块中的C层负责业务逻辑和业务展示, 而M和V应该是互相隔离的以做重用, 另外每个模块处理得当也可以作为重用单元. 拆分在于解耦, 顺便做了减负, 隔离在于重用, 提升开发效率. 缺点是没有区分业务逻辑和业务展示, 对单元测试不友好.

2.MVP作为MVC的进阶版, 提出区分业务逻辑和业务展示, 将所有的业务逻辑转移到P层, V层接受P层的数据更新通知进行页面展示. 优点在于良好的分层带来了友好的单元测试, 缺点在于分层会让代码逻辑优点绕, 同时也带来了大量的代码工作, 对程序员不够友好.

3.MVVM作为集大成者, 通过数据绑定做数据更新, 减少了大量的代码工作, 同时优化了代码逻辑, 只是学习成本有点高, 对新手不够友好.

4.MVP和MVVM因为分层所以会建立MVC两倍以上的文件类, 需要良好的代码管理方式.

5.在MVP和MVVM中, V和P或者VM之间理论上是多对多的关系, 不同的布局在相同的逻辑下只需要替换V层, 而相同的布局不同的逻辑只需要替换P或者VM层. 但实际开发中P或者VM往往因为耦合了V层的展示逻辑退化成了一对一关系(比如SceneA中需要显示”xxx+Name”, VM就将Name格式化为”xxx + Name”. 某一天SceneB也用到这个模块, 所有的点击事件和页面展示都一样, 只是Name展示为”yyy + Name”, 此时的VM因为耦合SceneA的展示逻辑, 就显得比较尴尬), 针对此类情况, 通常有两种办法, 一种是在VM层加状态进而判断输出状态, 一种是在VM层外再加一层FormatHelper. 前者可能因为状态过多显得代码难看, 后者虽然比较优雅且拓展性高, 但是过多的分层在数据还原时就略显笨拙, 大家应该按需选择.

这里随便瞎扯一句, 有些文章上来就说MVVM是为了解决C层臃肿, MVC难以测试的问题, 其实并不是这样的. 按照架构演进顺序来看, C层臃肿大部分是没有拆分好MVC模块, 好好拆分就行了, 用不着MVVM. 而MVC难以测试也可以用MVP来解决, 只是MVP也并非完美, 在VP之间的数据交互太繁琐, 所以才引出了MVVM. 当MVVM这个完全体出现以后, 我们从结果看起源, 发现它做了好多事情, 其实并不是, 它的前辈们付出的努力也并不少!

架构那么多, 日常开发中到底该如何选择?

不管是MVC, MVP, MVVM还是MVXXX, 最终的目的在于服务于人, 我们注重架构, 注重分层都是为了开发效率, 说到底还是为了开心. 所以, 在实际开发中不应该拘泥于某一种架构, 根据实际项目出发, 一般普通的MVC就能应对大部分的开发需求, 至于MVP和MVVM, 可以尝试, 但不要强制.

总之, 希望大家能做到: 设计时, 心中有数. 撸码时, 开心就好.

本文附带的demo地址

https://github.com/HeiHuaBaiHua/TMVX

银色金属分割线

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

推荐阅读更多精彩内容