[关闭]
@kezhen 2015-12-31T02:25:17.000000Z 字数 10850 阅读 2829

猿题库 iOS 客户端架构设计

MVC MVVM 猿题库 架构


1. 推荐序

我几周前写过一篇文章,叫《被误解的 MVC 和被神化的 MVVM》,其中的很多思想是和本文的作者 Lancy 交流获得的。当时很多人回复问:能直接上猿题库的代码吗?这次 Lancy 的这篇文章就直接上代码了。

这篇文章详细介绍了猿题库客户端架构的设计和思考,当然,也有大量的代码示例。Lancy 引入了一个名为 Data Controller 的层级为 View Controller 瘦身,并且借鉴了 MVVM 的思想来将界面与底层解耦。

这套架构帮助猿题库彻底解耦了UI和逻辑层的开发工作,并且使 View Controller 的代码极为精简,由于 Data Controller 与界面无关,它甚至使单元测试TDD 成为可能。

但是,好的架构都与具体的业务场景相关,希望大家在学习的时候也能充分理解它的试用场景,最终能够改造自己APP的架构。
希望能给大家帮助。

2. 序

猿题库是一个拥有数千万用户的创业公司,从20013年题库项目起步到2015年,团队保持了极高的生产效率,使我们的产品完成了五个大版本和数十个小版本的高速迭代。

在如此快速的开发过程中,如何保证代码的质量,降低后期维护的成本,以及为项目越来越快的版本迭代速度提供支持,成为了我们关注的重要问题。这篇文章将阐明我们在猿题库 iOS 客户端的架构设计。

3. MVC

MVC,Model-View-Controller,我们从这个古老而经典的设计模式入手。采用 MVC 这个架构的最大的优点在于其概念简单,易于理解,几乎任何一个程序员都会有所了解,几乎每一所计算机院校都教过相关的知识。而在 iOS 客户端开发中,MVC 作为官方推荐的主流架构,不但 SDK 已经为我们实现好了 UIView、UIViewController 等相关的组件,更是有大量的文档和范例供我们参考学习,可以说是一种非常通用而成熟的架构设计。

MVC 也有他的坏处。由于 MVC 的概念过于简单朴素,已经越来越难以适应如今客户端的需求,大量的代码逻辑在 MVC 中并没有定义得很清楚究竟应该放在什么地方,导致他们很容易就会堆积在 Controller 里,成为了人们所说的 Massive View Controller

4. MVVM

MVVM,Model-View-ViewModel,一个从 MVC 模式中进化而来的设计模式,最早于2005年被微软的 WPFSilverlight 的架构师 John Gossman 提出。在 iOS 开发中实践 MVVM 的话,通常会把大量原来放在 ViewController 里的视图逻辑和数据逻辑移到 ViewModel 里,从而有效的减轻了 ViewController 的负担。

另外通过分离出来的 ViewModel 获得了更好的测试性,我们可以针对 ViewModel 来测试,解决了界面元素难于测试的问题。MVVM 通常还会和一个强大的绑定机制一同工作,一旦 ViewModel 所对应的 Model 发生变化时,ViewModel 的属性也会发生变化,而相对应的 View 也随即产生变化。

同样的,MVVM 也有他的缺点:

一个首要的缺点是,MVVM 的学习成本和开发成本都很高。MVVM 是一个年轻的设计模式,大多数人对他的了解都不如 MVC 熟悉,基于绑定机制来进行编程需要一定的学习才能较好的上手。同时在 iOS 客户端开发中,并没有现成的绑定机制可以使用,要么使用 KVO,要么引入类似 ReactiveCocoa 这样的第三方库,使得学习成本和开发成本进一步提高。

另一个缺点是,数据绑定使 Debug 变得更难了。数据绑定使程序异常能快速的传递到其他位置,在界面上发现的 Bug 有可能是由 ViewModel 造成的,也有可能是由 Model 层造成的,传递链越长,对 Bug 的定位就越困难。

同时还必须指出的是,在传统的 MVVM 架构中,ViewModel 依然承载的大量的逻辑,包括业务逻辑,界面逻辑,数据存储和网络相关,使得 ViewModel 仍然有可能变得和 MVCViewController 一样臃肿。

5. 在两种架构中权衡而产生的架构

两种架构的优点都想要,缺点又都想避开,我们在两种架构中权衡了他们的优缺点,设计出了一个新的架构,起了一个名字叫:MVVM without Binding with DataController,架构图如下:
架构图

6. Show me the code

我们以猿题库主页为例,展示我们是如何使用应用这个架构的。
猿题库1
主页有几个部分组成,最上面的小猴子 Banner 页,用于滚动展示一些活动信息;中间有一个用户名字的页面,用于展示用户信息和答题情况以及一些心灵鸡汤;最底下的这部分是一个课目选择页面,展示了用户开启的科目入口,在更多选项里面可以进一步配置这些科目入口。接下来我们会以科目页面(SubjectView)为例展示一些细节。

  1. @interface APEHomePracticeViewController () <APEHomePracticeSubjectsViewDelegate>
  2. @property (nonatomic, strong, nullable) UIScrollView *contentView;
  3. @property (nonatomic, strong, nullable) APEHomePracticeBannerView *bannerView;
  4. @property (nonatomic, strong, nullable) APEHomePracticeActivityView *activityView;
  5. @property (nonatomic, strong, nullable) APEHomePracticeSubjectsView *subjectsView;
  6. @property (nonatomic, strong, nullable) APEHomePracticeDataController *dataController;
  7. @end
在 viewDidLoad 的时候,初始化好各个 SubView,并设置好布局:
  1. - (void)setupContentView {
  2. self.contentView = [[UIScrollView alloc] init];
  3. [self.view addSubview:self.contentView];
  4. self.bannerView = [[APEHomePracticeBannerView alloc] init];
  5. self.activityView = [[APEHomePracticeActivityView alloc] init];
  6. self.subjectsView = [[APEHomePracticeSubjectsView alloc] init];
  7. self.subjectsView.delegate = self;
  8. [self.contentView addSubview:self.bannerView];
  9. [self.contentView addSubview:self.activityView];
  10. [self.contentView addSubview:self.subjectsView];
  11. // Layout Views ...
  12. }
接下来,ViewController 会向 DataController 请求 Subject 相关的数据,并在请求完成后,用获得的数据生成 ViewModel,将其装配给 SubjectView,完成界面渲染,代码如下:
  1. - (void)fetchSubjectData {
  2. [self.dataController requestSubjectDataWithCallback:^(NSError *error) {
  3. if (error == nil) {
  4. [self renderSubjectView];
  5. }
  6. }];
  7. }
  8. - (void)renderSubjectView {
  9. APEHomePracticeSubjectsViewModel *viewModel =
  10. [APEHomePracticeSubjectsViewModel viewModelWithSubjects:self.dataController.openSubjects];
  11. [self.subjectsView bindDataWithViewModel:viewModel];
  12. }
  1. @interface APESubject : NSObject
  2. @property (nonatomic, strong, nullable) NSNumber *id;
  3. @property (nonatomic, strong, nullable) NSString *name;
  4. @end
  5. @interface APEUserSubject : NSObject
  6. @property (nonatomic, strong, nullable) NSNumber *id;
  7. @property (nonatomic, strong, nullable) NSNumber *updatedTime;
  8. /// On or Off
  9. @property (nonatomic) APEUserSubjectStatus status;
  10. @end
  1. // APEHomePracticeDataController.h
  2. @interface APEHomePracticeDataController : APEBaseDataController
  3. // 1
  4. @property (nonatomic, strong, nonnull, readonly) NSArray<APESubject *> *openSubjects;
  5. // 2
  6. - (void)requestSubjectDataWithCallback:(nonnull APECompletionCallback)callback;
  7. @end

上面的这个代码:

  1. 我们定义了一个界面最终需要的数据的 property,这里是 openSubjects,这个 property 会存储用户打开的科目列表,他的类型是APESubject

  2. 我们还会定义一个接口来请求 openSubject 数据。DataController 这一层是一个灵活性很高的部件,一个 DataController 可以复用更小的 DataController,这一类更小的 DataController 通常只会包含纯粹的或是更抽象的 Model 相关的逻辑,例如网络请求,数据库请求,或是数据加工等。我们称这一类 DataControllerModel Related Data ControllerModel Related Data Controller 通常会为上层提供正交的数据:

  1. // APEHomePracticeDataController.m
  2. @interface APEHomePracticeDataController ()
  3. @property (nonatomic, strong, nonnull) APESubjectDataController *subjectDataController;
  4. @end
  5. @implementation APEHomePracticeDataController
  6. - (void)requestSubjectDataWithCallback:(nonnull APECompletionCallback)callback {
  7. APEDataCallback dataCallback = ^(NSError *error, id data) {
  8. callback(error);
  9. };
  10. [self.subjectDataController requestAllSubjectsWithCallback:dataCallback];
  11. [self.subjectDataController requestUserSubjectsWithCallback:dataCallback];
  12. }
  13. - (nonnull NSArray<APESubject *> *)openSubjects {
  14. return self.subjectDataController.openSubjectsWithCurrentPhase ?: @[];
  15. }
  16. @end

在我们的 APEHomePraticeDataController 的实现中,就包含了一个 APESubjectDataController,这个 subjectDataController 会负责请求 All Subjects 和 User Subjects,并将其加工成上层所最终需要的 Open Subjects。(备注:这个例子里面的 callback 会回调多次是猿题库产品的需求,如有需要,可在这一层控制请求都完成后再调用上层回调)
事实上,Model Related Data Controller 可以一般性的认为就是大家经常在写的 Model 层代码,例如 UserAgent,UserService,PostService 之类的服务。之后读者若想重构就项目成这个架构,大可以不必纠结于形式,直接在 DataController 里调用旧有代码的逻辑即可,如图下面这样的行为都是允许的:
架构2

  1. @interface APEHomePracticeSubjectsViewModel : NSObject
  2. @property (nonatomic, strong, nonnull) NSArray<APEHomePracticeSubjectsCollectionCellViewModel *>
  3. *cellViewModels;
  4. @property (nonatomic, strong, nonnull) UIColor *backgroundColor;
  5. + (nonnull APEHomePracticeSubjectsViewModel *)viewModelWithSubjects:(nonnull NSArray<APESubject *>
  6. *)subjects;
  7. @end
ViewModel 可以包含更小的 ViewModel,就像 View 可以有 SubView 一样。SubjectView 的内部是由一个UICollectionView实现的,所以我们也给了对应的 Cell 设计了一个 ViewModel。
需要额外注意的是,ViewModel 一般来说会包含的显示界面所需要的所有元素,但粒度是可以控制。一般来说,我们只把会因为业务变化而变化的部分设为 ViewModel 的一部分,例如这里的 titleColor 和 backgroundColor 会因为主题不同而变化,但字体的大小(titleFont)却是不会变的,所以不需要事无巨细的都加到 ViewModel 里。
  1. @interface APEHomePracticeSubjectsCollectionCellViewModel : NSObject
  2. @property (nonatomic, strong, nonnull) UIImage *image;
  3. @property (nonatomic, strong, nonnull) UIImage *highlightedImage;
  4. @property (nonatomic, strong, nonnull) NSString *title;
  5. @property (nonatomic, strong, nonnull) UIColor *titleColor;
  6. @property (nonatomic, strong, nonnull) UIColor *backgroundColor;
  7. + (nonnull APEHomePracticeSubjectsCollectionCellViewModel *)viewModelWithSubject:(nonnull
  8. APESubject *)subject;
  9. + (nonnull APEHomePracticeSubjectsCollectionCellViewModel *)viewModelForMore;
  10. @end
  1. @protocol APEHomePracticeSubjectsViewDelegate <NSObject>
  2. - (void)homePracticeSubjectsView:(nonnull APEHomePracticeSubjectsView *)subjectView
  3. didPressItemAtIndex:(NSInteger)index;
  4. @end
  5. @interface APEHomePracticeSubjectsView : UIView
  6. @property (nonatomic, strong, nullable, readonly) APEHomePracticeSubjectsViewModel *viewModel;
  7. @property (nonatomic, weak, nullable) id<APEHomePracticeSubjectsViewDelegate> delegate;
  8. - (void)bindDataWithViewModel:(nonnull APEHomePracticeSubjectsViewModel *)viewModel;
  9. @end
渲染界面的时候,完全依靠 ViewModel 进行,包括 View 的 SubView 也会使用 ViewModel 里面的子 ViewModel 渲染。
  1. - (void)bindDataWithViewModel:(nonnull APEHomePracticeSubjectsViewModel *)viewModel {
  2. self.viewModel = viewModel;
  3. self.backgroundColor = viewModel.backgroundColor;
  4. [self.collectionView reloadData];
  5. [self setNeedsUpdateConstraints];
  6. }
  7. - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:
  8. (NSIndexPath *)indexPath {
  9. APEHomePracticeSubjectsCollectionViewCell *cell = [collectionView
  10. dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
  11. if (0 <= indexPath.row && indexPath.row < self.viewModel.cellViewModels.count) {
  12. APEHomePracticeSubjectsCollectionCellViewModel *vm =
  13. self.viewModel.cellViewModels[indexPath.row];
  14. [cell bindDataWithViewModel:vm];
  15. }
  16. return cell;
  17. }
至此,我们就完成了所有的步骤。我们回过头再看一下 ViewController 的职责就回变的非常简单,装配好 View,向 DataController 请求数据,装配 ViewModel,配置给 View,接收 View 的UI事,一切复杂的操作都能够的代理出去。

7. 总结

7.1 优点

通过上面的例子我们可以看到,这个架构有几个优点:

7.2 缺点

不可否认的是,这个设计也有其相应的缺点,由于其把传统 MVVM 里面的 VM 拆成两部分,会照成下面的一些情况:

8.后记

MVVM 是一个很棒的架构,私底下我也会用其来做一些个人项目,但在公司项目里,我会更慎重的考虑个中利弊。我做这个设计的时候,心仪 MVVM 的种种好处,又忌惮于它的种种坏处,再考虑到团队的开发和维护成本,所以最终设计成了如今这样。

个人认为,好的架构设计的都是和团队以及业务场景息息相关的。我们这套架构帮助我们解决了 ViewController 代码堆积的问题,也带来了更清晰明了的代码层级和模块职责,同时没有引入过多的复杂性。希望大家也能充分理解这套架构的适用场景,在自己的 APP 架构设计中有所借鉴。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注