ShannonChenCHN/iOS-App-Architecture

读『杂谈: MVC/MVP/MVVM』

ShannonChenCHN opened this issue · 1 comments

这篇文章按照架构的演进顺序,通过用三种不同的设计模式(MVC、MVP、MVVM)来实现一个示例场景,分别讲解了三种不同的设计模式各自的优缺点。

『杂谈: MVC/MVP/MVVM』

说明:下面两张图展示的是文章中用到的业务场景,图一是查看别人个人信息时的页面展示,图二是查看自己的个人信息的页面展示

2595746-29036860ea42fbf8

2595746-f98abdd550485408

一、MVC

1. 概念

  • 传统的 MVC 中,M 是指业务数据, V 是指用户界面, C 则是控制器
  • C 作为 M 和 V 之间的连接, 负责获取输入的业务数据, 然后将处理后的数据输出到界面上做相应展示, 另外, 在数据有所更新时, C 还需要及时同步到 UI 进行展示
  • M 和 V 之间是完全隔离的
  • 通常只需要替换相应的 C, M 和 V 是可以复用的

2. 现状:MVC 之消失的 C 层

  • 问题:随着需求的变更, C 变得越来越臃肿, 越来越难以维护, 拓展性和测试性也极差
  • 原因:如下图中的业务场景所示,就存在以下几个问题
    • 一个页面有多种数据,用来展示多个不同的 view,但是, 每一对 M-V 之间并没有一个独立的连接层 C,本来应该分散到各个 C 层处理的逻辑全部被打包丢到了 Scene (也就是我们常说的 ViewController)这一个地方处理, 也就是 M-C-V 变成了 MM...-Scene-...VV, C层就这样莫名其妙的消失了
    • V 直接耦合了 M, 这导致 V 和相应的 M 绑到一起了, 复用性差
    • 不易于测试, 因为业务初始化和销毁被绑定到了 ViewController 的生命周期上, 而相应的逻辑也关联到了 View 的点击事件(怎么理解?)

2595746-e8770c633cb5cc74

3. 正确的 MVC 使用姿势

误区:ViewController 就一定是 C 层?

正确的MVC应该是这个样子的:
2595746-2d7ea66f64955f87

  • 大体架构:

    • UserVC 作为业务场景 Scene, 需要展示三种数据, 对应的就有三个 MVC
    • 这三个 MVC 负责各自模块的数据获取, 数据处理和数据展示
    • UserVC 需要做的就是配置好这三个 MVC, 并在合适的时机通知各自的 C 层进行数据获取
    • 各个 C 层拿到数据后进行相应处理, 处理完成后渲染到各自的 View 上, UserVC 最后将已经渲染好的各个View 进行布局
  • 细节(以 Blog 模块为例)

    • Blog 的 MVC 其实又是由多个更小的 MVC 组成的:Blog 模块由 BlogTableViewHelper(C)、BlogTableView(V)、BlogCellHelpers(C) 构成,BlogCellHelpers 里面装的不是 M,而是 Cell 的 C 层 CellHelper (注:实际上从代码中看,作者这里的 BlogCellHelpers 更像是 ViewModel 的角色)
    • View 的创建
      • 可能是在 Scene 对应的 Storyboard 中新建并布局的
      • 如果是纯代码的方式,View 就需要相应模块自行创建了
    • BlogTableViewHelper 的功能
      • 对外提供获取数据和必要的构造方法接口, 内部根据自身情况进行相应的初始化
        • 数据获取
          • 当外部调用fetchData的接口后, Helper就会启动获取数据逻辑
          • 获取数据完成后,以CompletionHandler的形式交由Scene自己处理
          • 数据获取失败会展示相应的错误页面
          • 数据获取成功则建立更小的MVC部分并通知其展示数据
        • 处理 TableView 的上拉刷新和下拉加载逻辑
        • 页面跳转逻辑:由Scene通过VCGeneratorBlock直接配置的;或者通过didSelectRowHandler之类的方式传递数据到Scene层, 由Scene做跳转
  • 使用效果

    • 复用的粒度更细,更模块化(不再仅仅是一个 View,一个 Model),将来如果Blog模块会在另一个SceneX展示, 那么SceneX只需要新建一个 BlogTableViewHelper 对象, 然后调用一下helper.fetchData即可
    • 作为业务场景的的Scene(UserVC)做的事情很简单, 根据自身情况对三个模块进行配置(configuration), 布局(addUI), 然后通知各个模块启动(fetchData)就可以了, 因为每个模块的展示和交互是自管理的, 所以Scene只需要负责和自身业务强相关的部分即可

4. “新 MVC ” 解决了什么问题

  • 代码复用:三个小模块的V(cell/userInfoView)对外只暴露Set方法, 对M甚至C都是隔离状态, 复用完全没有问题。三个大模块的MVC也可以用于快速构建相似的业务场景,大模块的复用比小模块会差一些

  • 代码不再臃肿:因为Scene大部分的逻辑和布局都转移到了相应的MVC中, 我们仅仅是拼装MVC的便构建了两个不同的业务场景, 每个业务场景都能正常的进行相应的数据展示, 也有相应的逻辑交互, 而完成这些东西, 加空格也就100行代码左右

  • 易拓展性:无论产品未来想加回收站还是防御塔, 我需要的只是新建相应的MVC模块, 加到对应的Scene即可

  • 可维护性:各个模块间职责分离, 哪里出错改哪里, 完全不影响其他模块. 另外, 各个模块的代码其实并不算多, 哪一天即使写代码的人离职了, 接手的人根据错误提示也能快速定位出错模块

  • 易测试性:很遗憾, 业务的初始化依然绑定在Scene的生命周期中, 而有些逻辑也仍然需要UI的点击事件触发, 我们依然只能Command+R, 点点点...(注:这一点不太明白,为什么说测试性不好?😆

5. MVC 仍然存在的问题

  • 过度的注重隔离: 这个其实MV(x)系列都有这缺点, 为了实现V层的完全隔离, V对外只暴露Set方法, 一般情况下没什么问题, 但是当需要设置的属性很多时, 大量重复的Set方法写起来还是很累人的

  • 业务逻辑和业务展示强耦合: 可以看到, 有些业务逻辑(页面跳转/点赞/分享...)是直接散落在V层的, 这意味着我们在测试这些逻辑时, 必须首先生成对应的V, 然后才能进行测试. 显然, 这是不合理的. 因为业务逻辑最终改变的是数据M, 我们的关注点应该在M上, 而不是展示M的V(注:这里同样不太理解。。。

二、MVP

1. 简介

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

2. 案例分析

用 MVP 来实现前面提到的业务场景时,架构应该是这样的:
2595746-e8bf88209857beeb

  • 大体结构

    • 业务场景没有变化, 依然是展示三种数据, 只是三个 MVC 替换成了三个 MVP
    • Scene(UserVC)的任务就变成了:
      • 负责配置三个 MVP,新建各自的 V、P, 通过 P 建立 C, 各子 C 会负责建立 V-P 之间的绑定关系
      • 在合适的时机通知各自的 P 层(之前是通知 C 层)进行数据获取,各个 P 层在获取到数据后进行相应的数据处理,处理完成后会通知绑定的 View 数据有所更新,V 收到更新通知后从 P 获取格式化好的数据进行页面渲染
    • V 层和 C 层不再处理任何业务逻辑,所有由 View 产生的事件触发或者其他业务逻辑全部调用 P 层的相应命令
  • 业务逻辑被转移到了 P 层,此时的 V 层只需要做两件事:

    • 展示:监听 P 层的数据更新通知,刷新页面展示
    • 事件:在点击事件触发时,调用P层的对应方法,并对方法执行结果进行展示
  • C 层做的事情就是布局和 P-V 之间的绑定

  • 细节(以 blog 模块为例)

    • BlogViewController 现在不再负责实际的数据获取逻辑,数据获取直接调用 Presenter 的相应接口
    • Cell 获取数据的方式:替换了原来大量的 Set 方法,让 Cell 自己根据绑定的 CellPresenter 做展示
    • 现在逻辑都移到了 P 层,V 层要做相应的交互也必须依赖对应的 P 层命令,好在 V 和 M 仍然是隔离的,只是和 P 耦合了,P 层是可以随意替换的,M 显然不行,这是一种折中(为什么说 P 层可以随意替换,M 层不行???)
    • Scene 层的变动不大,只是替换配置 MVC 为配置 MVP,另外数据获取也是通过与 C 绑定的 P 获取的,而不是直接从 C 层获取了
  • One more thing

    • 问题:在上面的讨论中,我们假定,所有的事件都是由 V 层主动发起的(比如点赞),而且是一次性的,但是实际上存在一些例外的情况,比如非一次性的连续性事件、其它层发起的事件。
    • 示例:类似微信语音聊天之类的页面, 点击语音 Cell 开始播放,Cell 展示播放动画, 播放完成动画停止, 然后播放下一条语音
    • 解决方案:针对这些非一次性或者其他层发起事件,处理方法其实很简单,在 CellPresenter 加个 Block 属性就行了,因为是属性,Block 可以多次回调,另外 Block 还可以捕获 Cell,所以也不担心找不到对应的 Cell。播放的时候,C 只需要找到对应的 CellPresenter,然后传入相应的 playState,调用 didUpdatePlayStateHandler 就可以更新 Cell 的状态了:
@interface VoiceCell ()
@end
@implementation VoiceCell

- (void)setPresenter:(VoiceCellPresenter *)presenter {
    _presenter = presenter;

    if (!presenter.didUpdatePlayStateHandler) {
        __weak typeof(self) weakSelf = self;
        [presenter setDidUpdatePlayStateHandler:^(NSUInteger playState) {
            switch (playState) {
                case PlayStateBuffering: weakSelf.playButton... break;
                case PlayStatePlaying: weakSelf.playButton... break;
                case PlayStatePaused: weakSelf.playButton... break;
            }
        }];
    }
}
typedef void(^PlayCompletion)(NSError *error, id result);

typedef NS_ENUM(NSInteger, PlayState) {
    PlayStateBuffering,
    PlayStatePlaying,
    PlayStatePaused,
    PlayStateEnded,
};

@interface VoiceCellPresenter : NSObject

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

- (NSURL *)playURL;
- (void)playWithCompletionHandler:(PlayCompletion)completion;

@end

@implementation VoiceCellPresenter

- (void)playWithCompletionHandler:(PlayCompletion)comletion {
     ....
     // 播放完成后,调用 didUpdatePlayStateHandler
     if (self.didUpdatePlayStateHandler) {
          self.didUpdatePlayStateHandler(PlayStateEnded);
     }
}

@end
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    
    BlogViewCell *cell = [tableView dequeueReusableCellWithIdentifier:ReuseIdentifier];
    cell.presenter = self.presenter.allDatas[indexPath.row];
    
    __weak typeof(self) weakSelf = self;
    __weak typeof(cell) weakCell = cell;
    __block PlayCompletion playCompletion = nil;
    __block __weak PlayCompletion weakCompletion = playCompletion;
    playCompletion = ^(NSError *error, id result) {
                 // 找到下一个 cell 的 presenter
                 if (weakSelf.presenter.allDatas.count > indexPath.row + 1) {
                      BlogCellPresenter *nextPresenter = weakSelf.presenter.allDatas[indexPath.row + 1];
                      [nextPresenter  playWithCompletionHandler: weakCompletion];
                  }
        };

    [cell setDidPlayHandler:^{        
        [weakCell.presenter playWithCompletionHandler:playCompletion];
    }];
    return cell;
}

3. 小结

MVP 的主要**是把业务展示和业务逻辑分离开来,Presenter 层专门负责业务逻辑,View 层只管展示,Controller 将 Presenter 层和 View 层绑定起来,进行数据和事件通信。

三、MVVM

1. MVP 模式中仍然存在的问题

在 V 层和 P 层之间进行无耦合的通信一般有两种方式:delegate 或者 block,也就是说,如果想要在 P 层更新数据后,通知 V 层更新的话,是绕不过这两种方式的。这两种方式可以将 V 层和 P 层隔离开,以实现解耦。

这有什么问题吗?问题就在于为了让 V 跟着数据的状态改变一起变化,我们需要写很多“通信”代码,要么是定义 block 属性,要么是定义 delegate 方法,当 V 层的监听的数据越多,就要定义越多的属性或方法。

2. MVVM

除了 delegate 和 block 这两种通信方式之外,还有一种低耦合的通信方式就是 KVO。通过 KVO 的使用,我们可以省去很多胶水代码。

MVVM 与 MVP 相比,基本**还是差不多的——将业务逻辑从 C 层中分离出来,只是,MVVM 相比 MVP 来讲最主要的特点就在于使用了数据绑定。所以,我们可以说,MVVM 其实只是 MVP 的绑定进化体,除去数据绑定方式,其他的和 MVP 如出一辙,只是可能呈现方式是Command/Signal 而不是 CompletionHandler 之类的。(???)

当然实现数据绑定的方式有很多种,除了 Apple 官方提供的 KVO 之外,比较广为人知的就是 ReactiveCocoa(简称 RAC) 了。

四、总结

  • MVC:

    • 特点:
      • 将业务场景按数据类型划分为多个模块
      • 每个模块中 C 层负责业务逻辑和视图布局,M 和 V 层是相互独立解耦的,所以一般重用的都是 M 和 V
      • 某些情况下,单个模块其实也是可以重用的
      • 通过拆分来达到重用和减低复杂度的目的,通过解耦来达到提高可重用性、可维护性的目的
    • 缺点:
      • 没有将业务逻辑分离出来,不便于测试
  • MVP:

    • 特点:
      • 将业务逻辑从 C 层中分离出来,并转移到了 P 层
      • M 层仍然不变,只是独立的数据层
      • V 层会跟 P 绑定在一定,P 层数据更新时,会通知到 V 层
      • 易于测试,职责清晰,C 层的职责也更简单
    • 缺点:
      • 因为业务逻辑分离的缘故,代码逻辑会比较绕,不易理解,代码量也会比 MVC 多,写起来繁琐,而且还不易管理
  • MVVM:

    • 特点:
      • 与 MVP 做法一样,将业务逻辑从 C 层中分离出来
      • 同时还通过数据绑定进行数据更新
      • 代码量相比 MVP 会少很多,逻辑也更易于理解
    • 缺点:
      • 学习成本偏高,不易上手
  • 误区:MVVM 是为了解决 Controller 的臃肿和 MVC 难以测试问题

    • 按照架构演进顺序来看,就知道怎么回事了(真的是这样的演进历程吗???)
    • C 层臃肿主要是因为没有拆分好 MVC 模块
    • MVC 难以测试可以用 MVP 来解决
    • 因为使用 MVP 时在 V-P 之间的通信太繁琐,所以才引出了 MVVM
  • 实际开发中,到底选哪个?

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

五、遗留问题

  • 架构模式的演进真的是向作者所说的那样吗?
  • MVC/MVP/MVVM 的单元测试到底怎么测?
  • MVC 架构下通过拆分就能解决 Controller 臃肿的问题吗?
  • 文中通过 MVP 所实现的案例是否显得过于复杂?
  • 为什么有的时候是 MVP/MVVM, 有的时候又是 MVCP/MVCVM 呢?
    • 这取决于拆分的粒度,就拿 MVVM 和 MVCVM 来说,如果 View 不只是负责 UI 相关的内容,还包括数据绑定和事件绑定的话,那就是 MVVM,如果 View 只负责 UI 相关的内容,数据绑定和事件绑定交给单独的 Controller 去做的话,那就是 MVCVM。(详见原文示例代码)
  • V 层直接声明了 P/VM 的属性, 数据绑定又是写死的, 那不就是一对一了,怎么复用呢?
    • 因为 View 是复用的,那应该就是一对多的关系,但是这里一对一了,实际上,这里的 Presenter/ViewModel 只是提供了同一种接口,这也符合 View 所需要的数据和要处理的交互的规范,只是 Presenter/ViewModel 具体的实现根据实际需要来决定,内部可以根据不同的业务需求采用类簇模式来实现,所以本质上还是一对多。(详见原文示例代码)

六、收获

  • 对 MVC、MVP、MVVM 的全面了解,各自的优缺点