B08804eb3c90cdb2c666f6ff7f0e2d12
谈谈 MVX 中的 Controller

在前两篇文章中,我们已经对 iOS 中的 Model 层以及 View 层进行了分析,划分出了它们的具体职责,其中 Model 层除了负责数据的持久存储、缓存工作,还要负责所有 HTTP 请求的发出等工作;而对于 View 层的职责,我们并没有做出太多的改变,有的只是细分其内部的视图种类,以及分离 UIView 不应该具有的属性和功能。

如果想要具体了解笔者对 Model 层以及 View 层的理解和设计,这是前面两篇文章的链接:《谈谈 MVX 中的 Model 层》《谈谈 MVX 中的 View 层》

这是 MVX 系列的第三篇文章,而这篇文章准备介绍整个 MVX 中无法避免的话题,也就是 X 这一部分。

X 是什么

在进入正题之前,我们首先要知道这里的 X 到底是什么?无论是在 iOS 开发领域还是其它的领域,造出了一堆又一堆的名词,除了我们最常见的 MVC 和 MVVM 以及 Android 中的 MVP 还有一些其他的奇奇怪怪的名词。

模型层和视图层是整个客户端应用不可分割的一部分,它们的职责非常清楚,一个用于处理本地数据的获取以及存储,另一个用于展示内容、接受用户的操作与事件;在这种情况下,整个应用中的其它功能和逻辑就会被自然而然的扔到 X 层中。

这个 X 在 MVC 中就是 Controller 层、在 MVVM 中就是 ViewModel 层,而在 MVP中就是 Presenter 层,这篇文章介绍的就是 MVC 中的控制器层 Controller。

臃肿的 Controller

从 Cocoa Touch 框架使用十年以来,iOS 开发者就一直遵循框架中的设计,使用 Model-View-Controller 的架构模式开发 iOS 应用程序,下面也是对 iOS 中 MVC 的各层交互的最简单的说明。

iOS 中的 Model 层大多为 NSObject 的子类,也就是一个简单的对象;所有的 View 层对象都是 UIView 的子类;而 Controller 层的对象都是 UIViewController 的实例。
我们在这一节中主要是介绍 UIViewController 作为 Controller 层中的最重要的对象,它具有哪些职责,它与 Model 以及 View 层是如何进行交互的。
总体来说,Controller 层要负责以下的问题(包括但不仅限于):

  • 管理根视图的生命周期和应用生命周期
  • 负责将视图层的 UIView 对象添加到持有的根视图上;
  • 负责处理用户行为,比如 UIButton 的点击以及手势的触发;
  • 储存当前界面的状态;
  • 处理界面之间的跳转;
  • 作为 UITableView 以及其它容器视图的代理以及数据源;
  • 负责 HTTP 请求的发起;

除了上述职责外,UIViewController 对象还可能需要处理业务逻辑以及各种复杂的动画,这也就是为什么在 iOS 应用中的 Controller 层都非常庞大、臃肿的原因了,而 MVVM、MVP 等架构模式的目的之一就是减少单一 Controller 中的代码。

管理生命周期

Controller 层作为整个 MVC 架构模式的中枢,承担着非常重要的职责,不仅要与 Model 以及 View 层进行交互,还有通过 AppDelegate 与诸多的应用生命周期打交道。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(nullable NSDictionary<UIApplicationLaunchOptionsKey, id> *)launchOptions;
- (void)applicationWillResignActive:(UIApplication *)application;
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler;

虽然与应用生命周期沟通的工作并不在单独的 Controller 中,但是 self.window.rootController 作为整个应用程序界面的入口,还是需要在 AppDelegate 中进行设置。
除此之外,由于每一个 UIViewController 都持有一个视图对象,所以每一个 UIViewController 都需要负责这个根视图的加载、布局以及生命周期的管理,包括:

- (void)loadView;

- (void)viewWillLayoutSubviews;
- (void)viewDidLayoutSubviews;

- (void)viewDidLoad;
- (void)viewWillAppear:(BOOL)animated;
- (void)viewDidAppear:(BOOL)animated;

除了负责应用生命周期和视图生命周期,控制器还要负责展示内容和布局。

负责展示内容和布局

由于每一个 UIViewController 都持有一个 UIView 的对象,所以视图层的对象想要出现在屏幕上,必须成为这个根视图的子视图,也就是说视图层完全没有办法脱离 UIViewController 而单独存在,其一方面是因为 UIViewController 隐式的承担了应用中路由的工作,处理界面之间的跳转,另一方面就是 UIViewController 的设计导致了所有的视图必须加在其根视图上才能工作。

我们来看一段 UIViewController 中关于视图层的简单代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self setupUI];
}

- (void)setupUI {
    _backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"backgroundView"]];

    _registerButton = [[UIButton alloc] init];
    [_registerButton setTitle:@"注册" forState:UIControlStateNormal];
    [_registerButton setTitleColor:UIColorFromRGB(0x00C3F3) forState:UIControlStateNormal];
    [_registerButton addTarget:self action:@selector(registerButtonTapped:) forControlEvents:UIControlEventTouchUpInside];

    [self.view addSubview:_backgroundView];
    [self.view addSubview:_registerButton];

    [_backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.mas_equalTo(self.view);
    }];
    [_registerButton mas_makeConstraints:^(MASConstraintMaker *make) {
        make.size.mas_equalTo(CGSizeMake(140, 45));
        make.bottom.mas_equalTo(self.view).offset(-25);
        make.left.mas_equalTo(self.view).offset(32);
    }];
}

在这个欢迎界面以及大多数界面中,由于视图层的代码非常简单,我们很多情况下并不会去写一个单独的 UIView 类,而是将全部的视图层代码丢到了 UIViewController 中,这种情况下甚至也没有 Model 层,Controller 承担了全部的工作。

上述的代码对视图进行了初始化,将需要展示的视图加到了自己持有的根视图中,然后对这些视图进行简单的布局。

当然我们也可以将视图的初始化单独放到一个类中,不过仍然需要处理 DRKBackgroundView 视图的布局等问题。

- (void)setupUI {
    DRKBackgroundView *backgroundView = [[DRKBackgroundView alloc] init];
    [backgroundView.registerButton addTarget:self action:@selector(registerButtonTapped:) forControlEvents:UIControlEventTouchUpInside];

    [self.view addSubview:backgroundView];

    [backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.mas_equalTo(self.view);
    }];
}

UIViewController 的这种中心化的设计虽然简单,不过也导致了很多代码没有办法真正解耦,视图层必须依赖于 UIViewController 才能展示。

惰性初始化

当然,很多人在 Controller 中也会使用惰性初始化的方式生成 Controller 中使用的视图,比如:

@interface ViewController ()

@property (nonatomic, strong) UIImageView *backgroundView;

@end

@implementation ViewController

- (UIImageView *)backgroundView {
    if (!_backgroundView) {
        _backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"backgroundView"]];
    }
    return _backgroundView;
}

@end

这样在 -viewDidLoad 方法中就可以直接处理视图的视图层级以及布局工作:

- (void)viewDidLoad {
    [super viewDidLoad];

    [self.view addSubview:self.backgroundView];

    [self.backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.mas_equalTo(self.view);
    }];
}

惰性初始化的方法与其他方法其实并没有什么绝对的优劣,两者的选择只是对于代码规范的一种选择,我们所需要做的,只是在同一个项目中将其中一种做法坚持到底。

处理用户行为

在 UIViewController 中处理用户的行为是经常需要做的事情,这部分代码不能放到视图层或者其他地方的原因是,用户的行为经常需要与 Controller 的上下文有联系,比如,界面的跳转需要依赖于 UINavigationController 对象:

- (void)registerButtonTapped:(UIButton *)button {
    RegisterViewController *registerViewController = [[RegisterViewController alloc] init];
    [self.navigationController pushViewController:registerViewController animated:YES];
}

而有的用户行为需要改变模型层的对象、持久存储数据库中的数据或者发出网络请求,主要因为我们要秉承着 MVC 的设计理念,避免 Model 层和 View 层的直接耦合。

存储当前界面的状态

在 iOS 中,我们经常需要处理表视图,而在现有的大部分表视图在加载内容时都会进行分页,使用下拉刷新和上拉加载的方式获取新的条目,而这就需要在 Controller 层保存当前显示的页数:

@interface TableViewController ()

@property (nonatomic, assign) NSUInteger currentPage;

@end

只有保存在了当前页数的状态,才能在下次请求网络数据时传入合适的页数,最后获得正确的资源,当然哪怕当前页数是可以计算出来的,比如通过当前的 Model 对象的数和每页个 Model 数,在这种情况下,我们也需要在当前 Controller 中 Model 数组的值。

@interface TableViewController ()

@property (nonatomic, strong) NSArray<Model *> *models;

@end

在 MVC 的设计中,这种保存当前页面状态的需求是存在的,在很多复杂的页面中,我们也需要维护大量的状态,这也是 Controller 需要承担的重要职责之一。

处理界面之间的跳转

由于 Cocoa Touch 提供了 UINavigationController 和 UITabBarController 这两种容器 Controller,所以 iOS 中界面跳转的这一职责大部分都落到了 Controller 上。

iOS 中总共有三种界面跳转的方式:

  • UINavigationController 中使用 push 和 pop 改变栈顶的 UIViewController 对象;
  • UITabBarController 中点击各个 UITabBarItem 实现跳转;
  • 使用所有的 UIViewController 实例都具有的 -presentViewController:animated:completion 方法;

因为所有的 UIViewController 的实例都可以通过 navigationController 这一属性获取到最近的 UINavigationController 对象,所以我们不可避免的要在 Controller 层对界面之间的跳转进行操作。

当然,我们也可以引入 Router 路由对 UIViewController 进行注册,在访问合适的 URL 时,通过根 UINavigationController 进行跳转,不过这不是本篇文章想要说明的内容。

UINavigationController 提供的 API 还是非常简单的,我们可以直接使用 -pushViewController:animated: 就可以进行跳转。

RegisterViewController *registerViewController = [[RegisterViewController alloc] init];
[self.navigationController pushViewController:registerViewController animated:YES];

作为数据源以及代理

很多 Cocoa Touch 中视图层都是以代理的形式为外界提供接口的,其中最为典型的例子就是 UITableView 和它的数据源协议 UITableViewDataSource 和代理 UITableViewDelegate。

这是因为 UITableView 作为视图层的对象,需要根据 Model 才能知道自己应该展示什么内容,所以在早期的很多视图层组件都是用了代理的形式,从 Controller 或者其他地方获取需要展示的数据。

#pragma mark - UITableViewDataSource

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

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    TableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
    Model *model = self.models[indexPath.row];
    [cell setupWithModel:model];
    return cell;
}

上面就是使用 UITableView 时经常需要的方法。

很多文章中都提供了一种用于减少 Controller 层中代理方法数量的技巧,就是使用一个单独的类作为 UITableView 或者其他视图的代理:

self.tableView.delegate = anotherObject;
self.tableView.dataSource = anotherObject;

然而在笔者看来这种办法并没有什么太大的用处,只是将代理方法挪到了一个其他的地方,如果这个代理方法还依赖于当前 UIViewController 实例的上下文,还要向这个对象中传入更多的对象,反而让原有的 MVC 变得更加复杂了。

负责 HTTP 请求的发起

当用户的行为触发一些事件时,比如下拉刷新、更新 Model 的属性等等,Controller 就需要通过 Model 层提供的接口向服务端发出 HTTP 请求,这一过程其实非常简单,但仍然是 Controller 层的职责,也就是响应用户事件,并且更新 Model 层的数据。

- (void)registerButtonTapped:(UIButton *)button {
    LoginManager *manager = [LoginManager manager];
    manager.countryCode = _registerPanelView.countryCode;
    ...
    [manager startWithSuccessHandler:^(CCStudent *user) {
        self.currentUser = user;
        ...
    } failureHandler:^(NSError *error) {
        ...
    }];
}

当按钮被点击时 LoginManager 就会执行 -startWithSuccessHandler:failureHandler: 方法发起请求,并在请求结束后执行回调,更新 Model 的数据。

小结

iOS 中 Controller 层的职责一直都逃不开与 View 层和 Model 层的交互,因为其作用就是视图层的用户行为进行处理并更新视图的内容,同时也会改变模型层中的数据、使用 HTTP 请求向服务端请求新的数据等作用,其功能就是处理整个应用中的业务逻辑和规则。

但是由于 iOS 中 Controller 的众多职责,单一的 UIViewController 类可能会有上千行的代码,使得非常难以管理和维护,我们也希望在 iOS 中引入新的架构模式来改变 Controller 过于臃肿这一现状。

几点建议

Controller 层作为 iOS 应用中重要的组成部分,在 MVC 以及类似的架构下,笔者对于 Controller 的设计其实没有太多立竿见影的想法。作为应用中处理绝大多数逻辑的 Controller 其实很难简化其中代码的数量;我们能够做的,也是只对其中的代码进行一定的规范以提高它的可维护性,在这里,笔者有几点对于 Controller 层如何设计的建议,供各位读者参考。

不要把 DataSource 提取出来

iOS 中的 UITableView 和 UICollectionView 等需要 dataSource 的视图对象十分常见,在一些文章中会提议将数据源的实现单独放到一个对象中。
```
void (^configureCell)(PhotoCell, Photo) = ^(PhotoCell* cell, Photo* photo) {
cell.label.text = photo.name;
};
photosArrayDataSource = [[ArrayDataSource alloc] initWithItems:photos

top Created with Sketch.