iOS クライアント アーキテクチャ設計 MVVM

MVC

MVC、Model-View-Controller、この古くて古典的な設計パターンから始めます。MVC アーキテクチャを使用する最大の利点は、その概念がシンプルで理解しやすいことです。ほぼすべてのプログラマーがそれを理解でき、ほぼすべてのコンピューター スクールで関連知識を教えています。iOSクライアント開発においては、政府が推奨するMVCが主流のアーキテクチャであり、UIViewやUIViewControllerなどの関連コンポーネントがSDKに実装されているだけでなく、参考や検討に役立つドキュメントやサンプルも多数あり、非常に一般的で成熟したアーキテクチャ設計と言えます。

しかし、MVC には欠点もあります。MVC の概念がシンプルで単純すぎるため、今日のクライアントのニーズに適応することがますます困難になり、MVC では大量のコード ロジックがどこに配置されるべきかが明確に定義されていないため、簡単にコントローラーに山積みになり、俗に言う Massive View Controller になってしまいます。

MVVM

MVVM (Model-View-ViewModel) は、MVC パターンから発展した設計パターンであり、Microsoft の WPF および Silverlight アーキテクトである John Gossman によって 2005 年に初めて提案されました。iOS 開発で MVVM を実践する場合、通常、もともと ViewController に配置されていた多くのビュー ロジックとデータ ロジックを ViewModel に移動することにより、ViewController の負担が効果的に軽減されます。また、ViewModelを分離することでテスト容易性が向上し、ViewModelに対してテストを行うことができるため、インターフェース要素のテストが難しいという問題も解決されます。MVVM は通常、強力なバインディング メカニズムで動作するため、ViewModel に対応する Model が変更されると、ViewModel のプロパティも変更され、対応する View も即座に変更されます。

同様に、MVVM にも次のような欠点があります。

まず第一の欠点は、MVVM の学習と開発に費用がかかることです。MVVM は新しい設計パターンであり、ほとんどの人は MVC ほど慣れていないため、バインド メカニズムに基づいたプログラミングを開始するには、ある程度の学習が必要です。一方、iOS クライアントの開発では、利用できる既製のバインド機構が存在せず、KVO を使用するか、ReactiveCocoa などのサードパーティ ライブラリを導入する必要があり、学習コストと開発コストがさらに増加し​​ます。

もう 1 つの欠点は、データ バインディングによりデバッグが困難になることです。データ バインディングにより、プログラムの例外を他の場所にすばやく送信できます。インターフェイスで見つかったバグは、ViewModel または Model レイヤーによって引き起こされている可能性があります。送信チェーンが長くなるほど、バグを特定するのが難しくなります。

同時に、従来の MVVM アーキテクチャでは、ViewModel が依然としてビジネス ロジック、インターフェイス ロジック、データ ストレージ、ネットワーク関連などの大量のロジックを保持しているため、ViewModel が MVC の ViewController と同じくらい肥大化する可能性があることも同時に指摘しなければなりません。

2 つのアーキテクチャ間のトレードオフから生まれたアーキテクチャ

2 つのアーキテクチャの利点の両方を必要とし、欠点も避けたいと考えています。2 つのアーキテクチャの利点と欠点を比較検討し、新しいアーキテクチャを設計し、それを「MVVM without Binding with DataController」と名付けました。アーキテクチャ図は次のとおりです。

描く描く

ビューモデル

まずは右側のビューに関連する部分を見てみましょう. 従来のMVCのViewControllerにはデータ表示やスタイルカスタマイズのためのロジックがたくさんありましたが、MVVMではViewModelの概念を導入し、ビューロジックのこの部分をViewModelに移動します。この設計では、各ビューには対応する ViewModel があり、これには、このビューのデータ表示とスタイルのカスタマイズに必要なすべてのデータが含まれます。同時に、双方向バインディング メカニズムや監視メカニズムは導入しませんが、従来のプロキシ コールバックや通知を通じて UI イベントを外部に渡します。ViewController は ViewModel を生成し、それを対応する View にアセンブルし、対応する UI イベントを受け入れるだけで済みます。

これにはいくつかの利点があります: まず、ビューの完全な分離です。ビューの場合、対応する ViewModel と、モデル層から完全に分離される UI イベントのコールバック インターフェイスを決定するだけで済みます。一方、ViewController は View の特定のパフォーマンスの処理を回避でき、責任のこの部分は ViewModel に移されるため、ViewController の負担が効果的に軽減されます。同時に、従来のバインディング メカニズムを放棄し、従来のわかりやすいコールバック メカニズムを使用して UI イベントを渡すため、学習コストが削減されます。データの流入と流出の観察と制御が容易になり、メンテナンスとチューニングのコストが削減されます。

データコントローラー

次に、モデルと VC の関係に焦点を当てます。前述したように、従来の MVVM では、データの取得、処理、処理など、ViewController の役割のほとんどを ViewModel が引き継ぐため、肥大化しがちでした。ロジックのこの部分を抽出し、新しいコンポーネント DataController を導入します。

ViewController は、DataController にデータの取得または操作をリクエストでき、また、一部のイベントを DataController に渡すこともでき、これらのイベントは UI イベントによってトリガーできます。DataController はこれらのリクエストを受信すると、Model 層からデータを取得または更新し、最終的に取得したデータを ViewController が最終的に必要とするデータに加工して返します。

そうすることで、データ関連のロジックが分離され、データの取得、変更、処理がすべてデータ コントローラーで処理されます。ビュー コントローラーはデータの取得方法や処理方法を気にせず、データ コントローラーはインターフェイスの表示方法や操作方法を気にしません。同時に、データ コントローラーはインターフェイスとは何の関係もないため、テスト容易性と再利用性が向上します。

DataController 层和 Model 层之间的界限并不是僵硬的,但需要保证每一个 ViewController 都有一个对应的 DataController。Data Controller 更强调的是其作为业务逻辑对外的接口。而在 DataController 中调用更底层的 Model 层逻辑是我们推荐的编程范式,例如数据加工层,网络层,持久层等。

在后面的例子中,我们会更详细的讲解 DataController 的实现细节。

Show me the code

我们以猿题库主页为例,展示我们是如何使用应用这个架构的。

描くDrawing

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

ViewController

我们会给每一个 ViewController 都创建一个对应的 DataController。 例如我们给主页建一个类起名叫APEHomePraticeViewController,同时他会有一个对应的 DataController 起名叫 APEHomePraticeDataController。同时我们把页面拆分为几个部分,每个部分有一个相对应的 SubView。代码如下:

1
2
3
4
5
6
7
8
9
10
11
@interface APEHomePracticeViewController () <APEHomePracticeSubjectsViewDelegate>
  
@property (nonatomic, strong, nullable) UIScrollView *contentView;

@property (nonatomic, strong, nullable) APEHomePracticeBannerView *bannerView;
@property (nonatomic, strong, nullable) APEHomePracticeActivityView *activityView;
@property (nonatomic, strong, nullable) APEHomePracticeSubjectsView *subjectsView;
  
@property (nonatomic, strong, nullable) APEHomePracticeDataController *dataController;
  
@end

在 viewDidLoad 的时候,初始化好各个 SubView,并设置好布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)setupContentView {
    self.contentView = [[UIScrollView alloc] init];
    [self.view addSubview:self.contentView];

    self.bannerView = [[APEHomePracticeBannerView alloc] init];
    self.activityView = [[APEHomePracticeActivityView alloc] init];
    self.subjectsView = [[APEHomePracticeSubjectsView alloc] init];
    self.subjectsView.delegate = self;

    [self.contentView addSubview:self.bannerView];
    [self.contentView addSubview:self.activityView];
    [self.contentView addSubview:self.subjectsView];
    // Layout Views ...
}

接下来,ViewController 会向 DataController 请求 Subject 相关的数据,并在请求完成后,用获得的数据生成 ViewModel,将其装配给 SubjectView,完成界面渲染,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)fetchSubjectData {
    [self.dataController requestSubjectDataWithCallback:^(NSError *error) {
        if (error == nil) {
            [self renderSubjectView];
      }
  }];
}
- (void)renderSubjectView {
    APEHomePracticeSubjectsViewModel *viewModel =
        [APEHomePracticeSubjectsViewModel viewModelWithSubjects:self.dataController.openSubjects];
    [self.subjectsView bindDataWithViewModel:viewModel];
}

数据结构

为了更好的演示,我们接下来要介绍一下 Subject 相关的数据结构:

APESubject 是科目的资源结构,包含了 Subject 的 id 和 name 等资源属性,这部分属性是用户无关的;APEUserSubject是用户的科目信息,包含了用户是否打开某个学科的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface APESubject : NSObject

@property (nonatomic, strong, nullable) NSNumber *id;
@property (nonatomic, strong, nullable) NSString *name;

@end

@interface APEUserSubject : NSObject

@property (nonatomic, strong, nullable) NSNumber *id;
@property (nonatomic, strong, nullable) NSNumber *updatedTime;
///  On or Off
@property (nonatomic) APEUserSubjectStatus status;

@end

DataController

如我们之前所说,每一个 ViewController 都会有一个对应的 DataController,这一类 DataController 的主要职责是处理这个页面上的所有数据相关的逻辑,我们称其为 View Related Data Controller。

1
2
3
4
5
6
7
8
// APEHomePracticeDataController.h
@interface APEHomePracticeDataController : APEBaseDataController
// 1
@property (nonatomic, strong, nonnull, readonly) NSArray<APESubject *> *openSubjects;
// 2
- (void)requestSubjectDataWithCallback:(nonnull APECompletionCallback)callback;

@end

上面的这个代码

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

DataController 这一层是一个灵活性很高的部件,一个 DataController 可以复用更小的 DataController,这一类更小的 DataController 通常只会包含纯粹的或是更抽象的 Model 相关的逻辑,例如网络请求,数据库请求,或是数据加工等。我们称这一类 DataController 为 Model Related Data Controller。

Model Related Data Controller 通常会为上层提供正交的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// APEHomePracticeDataController.m
@interface APEHomePracticeDataController ()

@property (nonatomic, strong, nonnull) APESubjectDataController *subjectDataController;

@end

@implementation APEHomePracticeDataController

- (void)requestSubjectDataWithCallback:(nonnull APECompletionCallback)callback {
    APEDataCallback dataCallback = ^(NSError *error, id data) {
        callback(error);
    };
    [self.subjectDataController requestAllSubjectsWithCallback:dataCallback];
    [self.subjectDataController requestUserSubjectsWithCallback:dataCallback];
}

- (nonnull NSArray<APESubject *> *)openSubjects {
    return self.subjectDataController.openSubjectsWithCurrentPhase ?: @[];
}

@end

在我们的 APEHomePraticeDataController 的实现中,就包含了一个 APESubjectDataController,这个subjectDataController 会负责请求 All Subjects 和 User Subjects,并将其加工成上层所最终需要的 Open Subjects。(备注:这个例子里面的 callback 会回调多次是猿题库产品的需求,如有需要,可在这一层控制请求都完成后再调用上层回调)

事实上,Model Related Data Controller 可以一般性的认为就是大家经常在写的 Model 层代码,例如 UserAgent,UserService,PostService 之类的服务。之后读者若想重构就项目成这个架构,大可以不必纠结于形式,直接在 DataController 里调用旧有代码的逻辑即可,如图下面这样的行为都是允许的:

描くDrawing

ViewModel

每一个 View 都会有一个对应的 ViewModel,这个 ViewModel 会包含展示这个 View 所需要的所有数据。

我们会使用工厂方法来创建 View Model,例如这个例子里,Subject View Model 不需要关心传递给他是什么样的 Subject,所有的课目或者只是用户开启的科目。

1
2
3
4
5
6
7
8
9
10
11
@interface APEHomePracticeSubjectsViewModel : NSObject

@property (nonatomic, strong, nonnull) NSArray<APEHomePracticeSubjectsCollectionCellViewModel *>
*cellViewModels;

@property (nonatomic, strong, nonnull) UIColor *backgroundColor;

+ (nonnull APEHomePracticeSubjectsViewModel *)viewModelWithSubjects:(nonnull NSArray<APESubject *>
 *)subjects;

@end

ViewModel 可以包含更小的 ViewModel,就像 View 可以有 SubView 一样。SubjectView 的内部是由一个UICollectionView实现的,所以我们也给了对应的 Cell 设计了一个 ViewModel。

需要额外注意的是,ViewModel 一般来说会包含的显示界面所需要的所有元素,但粒度是可以控制。一般来说,我们只把会因为业务变化而变化的部分设为 ViewModel 的一部分,例如这里的 titleColor 和 backgroundColor 会因为主题不同而变化,但字体的大小(titleFont)却是不会变的,所以不需要事无巨细的都加到 ViewModel 里。

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface APEHomePracticeSubjectsCollectionCellViewModel : NSObject

@property (nonatomic, strong, nonnull) UIImage *image;
@property (nonatomic, strong, nonnull) UIImage *highlightedImage;
@property (nonatomic, strong, nonnull) NSString *title;
@property (nonatomic, strong, nonnull) UIColor *titleColor;
@property (nonatomic, strong, nonnull) UIColor *backgroundColor;

+ (nonnull APEHomePracticeSubjectsCollectionCellViewModel *)viewModelWithSubject:(nonnull
APESubject *)subject;
+ (nonnull APEHomePracticeSubjectsCollectionCellViewModel *)viewModelForMore;

@end

View

View 只需要定义好装配 ViewModel 的接口和定义好 UI 回调事件即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@protocol APEHomePracticeSubjectsViewDelegate <NSObject>

- (void)homePracticeSubjectsView:(nonnull APEHomePracticeSubjectsView *)subjectView
             didPressItemAtIndex:(NSInteger)index;

@end

@interface APEHomePracticeSubjectsView : UIView

@property (nonatomic, strong, nullable, readonly) APEHomePracticeSubjectsViewModel *viewModel;
@property (nonatomic, weak, nullable) id<APEHomePracticeSubjectsViewDelegate> delegate;

- (void)bindDataWithViewModel:(nonnull APEHomePracticeSubjectsViewModel *)viewModel;

@end

渲染界面的时候,完全依靠 ViewModel 进行,包括 View 的 SubView 也会使用 ViewModel 里面的子 ViewModel 渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)bindDataWithViewModel:(nonnull APEHomePracticeSubjectsViewModel *)viewModel {
    self.viewModel = viewModel;
    self.backgroundColor = viewModel.backgroundColor;
    [self.collectionView reloadData];
    [self setNeedsUpdateConstraints];
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:
(NSIndexPath *)indexPath {
    APEHomePracticeSubjectsCollectionViewCell *cell = [collectionView
dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
    if (0 <= indexPath.row && indexPath.row < self.viewModel.cellViewModels.count) {
        APEHomePracticeSubjectsCollectionCellViewModel *vm =
self.viewModel.cellViewModels[indexPath.row];
        [cell bindDataWithViewModel:vm];
}
    return cell;
}

至此,我们就完成了所有的步骤。我们回过头再看一下 ViewController 的职责就回变的非常简单,装配好 View,向 DataController 请求数据,装配 ViewModel,配置给 View,接收 View 的UI事,一切复杂的操作都能够的代理出去。

总结

优点

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

层次清晰,职责明确:和界面有关的逻辑完全划到 ViewModel 和 View 一遍,其中 ViewModel 负责界面相关逻辑,View 负责绘制;Data Controller 负责页面相关的数据逻辑,而 Model 还是负责纯粹的数据层逻辑。 ViewController 仅仅只是充当简单的胶水作用。

耦合度低,测试性高:除开 ViewController 外,各个部件可以说是完全解耦合的,各个部分也是可以完全独立测试的。同一个功能,可以分别由不同的开发人员分别进行开发界面和逻辑,只需要确立好接口即可。

复用性高:解耦合带来的额外好处就是复用性高,例如同一个View,只需要多一个工厂方法生成 ViewModel,就可以直接复用。数据逻辑代码不放在 ViewController 层也可以更方便的复用。

学习成本低: 本质上来说,这个架构属于对 MVC 的优化,主要在于解决 Massive View Controller 问题,把原本属于 View Controller 的职责根据界面和逻辑部分相应的拆到 ViewModel 和 DataController 当中,所以是一个非常易于理解的架构设计,即使是新手也可以很快上手。

开发成本低: 完全不需要引入任何第三方库就可以进行开发,也避免了因为 MVVM 维护成本高的问题。

实施性高,重构成本低:可以在 MVC 架构上逐步重构的架构,不需要整体重写,是一种和 MVC 兼容的设计。

缺点

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

  1. 当页面的交互逻辑非常多时,需要频繁的在 DC-VC-VM 里来回传递信息,造成了大量胶水代码。
  2. 另外,由于在传统的 MVVM 中 VM 原本是一体的,一些复杂的交互本来可以在 VM 中直接完成测试,如今却需要同时使用 DC 和 VM 并附上一些胶水代码才能进行测试。
  3. 没有了 Binding,代码写起来会更费劲一点(仁者见仁,智者见智)。

后记

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

個人的には、優れたアーキテクチャ設計はチームやビジネス シナリオと密接に関係していると考えています。私たちのアーキテクチャは、ViewController コードの蓄積の問題を解決するのに役立ち、あまり複雑にすることなく、より明確なコード階層とモジュールの責任ももたらしました。誰もがこのアーキテクチャの適用可能なシナリオを完全に理解し、そこから独自の APP アーキテクチャの設計で学ぶことができることを願っています。

ランシー

2015.12.30


http://gracelancy.com/blog/2016/01/06/ape-ios-arch-design/

おすすめ

転載: blog.csdn.net/args_/article/details/52874464