雜談: MVC/MVP/MVVM (一)
前言
本文為回答一位朋友關(guān)于MVC/MVP/MVVM架構(gòu)方面的疑問所寫,旨在介紹iOS下MVC/MVP/MVVM三種架構(gòu)的設(shè)計思路以及各自的優(yōu)缺點(diǎn)。
MVC
- MVC的相關(guān)概念
MVC最早存在于桌面程序中的, M是指業(yè)務(wù)數(shù)據(jù), V是指用戶界面, C則是控制器. 在具體的業(yè)務(wù)場景中, C作為M和V之間的連接, 負(fù)責(zé)獲取輸入的業(yè)務(wù)數(shù)據(jù), 然后將處理后的數(shù)據(jù)輸出到界面上做相應(yīng)展示, 另外, 在數(shù)據(jù)有所更新時, C還需要及時提交相應(yīng)更新到界面展示. 在上述過程中, 因為M和V之間是完全隔離的, 所以在業(yè)務(wù)場景切換時, 通常只需要替換相應(yīng)的C, 復(fù)用已有的M和V便可快速搭建新的業(yè)務(wù)場景. MVC因其復(fù)用性, 大大提高了開發(fā)效率, 現(xiàn)已被廣泛應(yīng)用在各端開發(fā)中.
概念過完了, 下面來看看, 在具體的業(yè)務(wù)場景中MVC/MVP/MVVM都是如何表現(xiàn)的.
- MVC之消失的C層
上圖中的頁面(業(yè)務(wù)場景)或者類似頁面相信大家做過不少, 各個程序員的具體實現(xiàn)方式可能各不一樣, 這里說說我所看到的部分程序員的寫法:
- //UserVC
- - (void)viewDidLoad {
- [super viewDidLoad];
- [[UserApi new] fetchUserInfoWithUserId:132 completionHandler:^(NSError *error, id result) {
- if (error) {
- [self showToastWithText:@"獲取用戶信息失敗了~"];
- } else {
- self.userIconIV.image = ...
- self.userSummaryLabel.text = ...
- ...
- }
- }];
- [[userApi new] fetchUserBlogsWithUserId:132 completionHandler:^(NSError *error, id result) {
- if (error) {
- [self showErrorInView:self.tableView info:...];
- } else {
- [self.blogs addObjectsFromArray:result];
- [self.tableView reloadData];
- }
- }];
- }
- //...略
- - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
- BlogCell *cell = [tableView dequeueReusableCellWithIdentifier:@"BlogCell"];
- cell.blog = self.blogs[indexPath.row];
- return cell;
- }
- - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
- [self.navigationController pushViewController:[BlogDetailViewController instanceWithBlog:self.blogs[indexPath.row]] animated:YES];
- }
- //...略
- //BlogCell
- - (void)setBlog:(Blog)blog {
- _blog = blog;
- self.authorLabel.text = blog.blogAuthor;
- self.likeLebel.text = [NSString stringWithFormat:@"贊 %ld", blog.blogLikeCount];
- ...
- }
程序員很快寫完了代碼, Command+R一跑, 沒有問題, 心滿意足的做其他事情去了. 后來有一天, 產(chǎn)品要求這個業(yè)務(wù)需要改動, 用戶在看他人信息時是上圖中的頁面, 看自己的信息時, 多一個草稿箱的展示, 像這樣:
于是小白將代碼改成這樣:
- //UserVC
- - (void)viewDidLoad {
- [super viewDidLoad];
- if (self.userId != LoginUserId) {
- self.switchButton.hidden = self.draftTableView.hidden = YES;
- self.blogTableView.frame = ...
- }
- [[UserApi new] fetchUserI......略
- [[UserApi new] fetchUserBlogsWithUserId:132 completionHandler:^(NSError *error, id result) {
- //if Error...略
- [self.blogs addObjectsFromArray:result];
- [self.blogTableView reloadData];
- }];
- [[userApi new] fetchUserDraftsWithUserId:132 completionHandler:^(NSError *error, id result) {
- //if Error...略
- [self.drafts addObjectsFromArray:result];
- [self.draftTableView reloadData];
- }];
- }
- - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
- return tableView == self.blogTableView ? self.blogs.count : self.drafts.count;
- }
- //...略
- - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
- if (tableView == self.blogTableView) {
- BlogCell *cell = [tableView dequeueReusableCellWithIdentifier:@"BlogCell"];
- cell.blog = self.blogs[indexPath.row];
- return cell;
- } else {
- DraftCell *cell = [tableView dequeueReusableCellWithIdentifier:@"DraftCell"];
- cell.draft = self.drafts[indexPath.row];
- return cell;
- }
- }
- - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
- if (tableView == self.blogTableView) ...
- }
- //...略
- //DraftCell
- - (void)setDraft:(draft)draft {
- _draft = draft;
- self.draftEditDate = ...
- }
- //BlogCell
- - (void)setBlog:(Blog)blog {
- ...同上
- }
后來啊, 產(chǎn)品覺得用戶看自己的頁面再加個回收站什么的會很好, 于是程序員又加上一段代碼邏輯 , 再后來…
隨著需求的變更, UserVC變得越來越臃腫, 越來越難以維護(hù), 拓展性和測試性也極差. 程序員也發(fā)現(xiàn)好像代碼寫得有些問題, 但是問題具體出在哪里? 難道這不是MVC嗎?
我們將上面的過程用一張圖來表示:
通過這張圖可以發(fā)現(xiàn), 用戶信息頁面作為業(yè)務(wù)場景Scene需要展示多種數(shù)據(jù)M(Blog/Draft/UserInfo), 所以對應(yīng)的有多個View(blogTableView/draftTableView/image…), 但是, 每個MV之間并沒有一個連接層C, 本來應(yīng)該分散到各個C層處理的邏輯全部被打包丟到了Scene這一個地方處理, 也就是M-C-V變成了MM…-Scene-…VV, C層就這樣莫名其妙的消失了.
另外, 作為V的兩個cell直接耦合了M(blog/draft), 這意味著這兩個V的輸入被綁死到了相應(yīng)的M上, 復(fù)用無從談起.
***, 針對這個業(yè)務(wù)場景的測試異常麻煩, 因為業(yè)務(wù)初始化和銷毀被綁定到了VC的生命周期上, 而相應(yīng)的邏輯也關(guān)聯(lián)到了和View的點(diǎn)擊事件, 測試只能Command+R, 點(diǎn)點(diǎn)點(diǎn)…
- 正確的MVC使用姿勢
也許是UIViewController的類名給新人帶來了迷惑, 讓人誤以為VC就一定是MVC中的C層, 又或許是Button, Label之類的View太過簡單完全不需要一個C層來配合, 總之, 我工作以來經(jīng)歷的項目中見過太多這樣的”MVC”. 那么, 什么才是正確的MVC使用姿勢呢?
仍以上面的業(yè)務(wù)場景舉例, 正確的MVC應(yīng)該是這個樣子的:
UserVC作為業(yè)務(wù)場景, 需要展示三種數(shù)據(jù), 對應(yīng)的就有三個MVC, 這三個MVC負(fù)責(zé)各自模塊的數(shù)據(jù)獲取, 數(shù)據(jù)處理和數(shù)據(jù)展示, 而UserVC需要做的就是配置好這三個MVC, 并在合適的時機(jī)通知各自的C層進(jìn)行數(shù)據(jù)獲取, 各個C層拿到數(shù)據(jù)后進(jìn)行相應(yīng)處理, 處理完成后渲染到各自的View上, UserVC***將已經(jīng)渲染好的各個View進(jìn)行布局即可, 具體到代碼中如下:
- @interface BlogTableViewHelper : NSObject
- + (instancetype)helperWithTableView:(UITableView *)tableView userId:(NSUInteger)userId;
- - (void)fetchDataWithCompletionHandler:(NetworkTaskCompletionHander)completionHander;
- - (void)setVCGenerator:(ViewControllerGenerator)VCGenerator;
- @end
- @interface BlogTableViewHelper()
- @property (weak, nonatomic) UITableView *tableView;
- @property (copy, nonatomic) ViewControllerGenerator VCGenerator;
- @property (assign, nonatomic) NSUInteger userId;
- @property (strong, nonatomic) NSMutableArray *blogs;
- @property (strong, nonatomic) UserAPIManager *apiManager;
- @end
- #define BlogCellReuseIdentifier @"BlogCell"
- @implementation BlogTableViewHelper
- + (instancetype)helperWithTableView:(UITableView *)tableView userId:(NSUInteger)userId {
- return [[BlogTableViewHelper alloc] initWithTableView:tableView userId:userId];
- }
- - (instancetype)initWithTableView:(UITableView *)tableView userId:(NSUInteger)userId {
- if (self = [super init]) {
- self.userId = userId;
- tableView.delegate = self;
- tableView.dataSource = self;
- self.apiManager = [UserAPIManager new];
- self.tableView = tableView;
- __weak typeof(self) weakSelf = self;
- [tableView registerClass:[BlogCell class] forCellReuseIdentifier:BlogCellReuseIdentifier];
- tableView.header = [MJRefreshAnimationHeader headerWithRefreshingBlock:^{//下拉刷新
- [weakSelf.apiManage refreshUserBlogsWithUserId:userId completionHandler:^(NSError *error, id result) {
- //...略
- }];
- }];
- tableView.footer = [MJRefreshAnimationFooter headerWithRefreshingBlock:^{//上拉加載
- [weakSelf.apiManage loadMoreUserBlogsWithUserId:userId completionHandler:^(NSError *error, id result) {
- //...略
- }];
- }];
- }
- return self;
- }
- #pragma mark - UITableViewDataSource && Delegate
- //...略
- - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
- return self.blogs.count;
- }
- - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
- BlogCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogCellReuseIdentifier];
- BlogCellHelper *cellHelper = self.blogs[indexPath.row];
- if (!cell.didLikeHandler) {
- __weak typeof(cell) weakCell = cell;
- [cell setDidLikeHandler:^{
- cellHelper.likeCount += 1;
- weakCell.likeCountText = cellHelper.likeCountText;
- }];
- }
- cell.authorText = cellHelper.authorText;
- //...各種設(shè)置
- return cell;
- }
- - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
- [self.navigationController pushViewController:self.VCGenerator(self.blogs[indexPath.row]) animated:YES];
- }
- #pragma mark - Utils
- - (void)fetchDataWithCompletionHandler:(NetworkTaskCompletionHander)completionHander {
- [[UserAPIManager new] refreshUserBlogsWithUserId:self.userId completionHandler:^(NSError *error, id result) {
- if (error) {
- [self showErrorInView:self.tableView info:error.domain];
- } else {
- for (Blog *blog in result) {
- [self.blogs addObject:[BlogCellHelper helperWithBlog:blog]];
- }
- [self.tableView reloadData];
- }
- completionHandler ? completionHandler(error, result) : nil;
- }];
- }
- //...略
- @end
- @implementation BlogCell
- //...略
- - (void)onClickLikeButton:(UIButton *)sender {
- [[UserAPIManager new] likeBlogWithBlogId:self.blogId userId:self.userId completionHandler:^(NSError *error, id result) {
- if (error) {
- //do error
- } else {
- //do success
- self.didLikeHandler ? self.didLikeHandler() : nil;
- }
- }];
- }
- @end
- @implementation BlogCellHelper
- - (NSString *)likeCountText {
- return [NSString stringWithFormat:@"贊 %ld", self.blog.likeCount];
- }
- //...略
- - (NSString *)authorText {
- return [NSString stringWithFormat:@"作者姓名: %@", self.blog.authorName];
- }
- @end
Blog模塊由BlogTableViewHelper(C), BlogTableView(V), Blogs(C)構(gòu)成, 這里有點(diǎn)特殊, blogs里面裝的不是M, 而是Cell的C層CellHelper, 這是因為Blog的MVC其實又是由多個更小的MVC組成的. M和V沒什么好說的, 主要說一下作為C的TableVIewHelper做了什么.
實際開發(fā)中, 各個模塊的View可能是在Scene對應(yīng)的Storyboard中新建并布局的, 此時就不用各個模塊自己建立View了(比如這里的BlogTableViewHelper), 讓Scene傳到C層進(jìn)行管理就行了, 當(dāng)然, 如果你是純代碼的方式, 那View就需要相應(yīng)模塊自行建立了(比如下文的UserInfoViewController), 這個看自己的意愿, 無傷大雅.
BlogTableViewHelper對外提供獲取數(shù)據(jù)和必要的構(gòu)造方法接口, 內(nèi)部根據(jù)自身情況進(jìn)行相應(yīng)的初始化.
當(dāng)外部調(diào)用fetchData的接口后, Helper就會啟動獲取數(shù)據(jù)邏輯, 因為數(shù)據(jù)獲取前后可能會涉及到一些頁面展示(HUD之類的), 而具體的展示又是和Scene直接相關(guān)的(有的Scene展示的是HUD有的可能展示的又是一種樣式或者根本不展示), 所以這部分會以CompletionHandler的形式交由Scene自己處理.
在Helper內(nèi)部, 數(shù)據(jù)獲取失敗會展示相應(yīng)的錯誤頁面, 成功則建立更小的MVC部分并通知其展示數(shù)據(jù)(也就是通知CellHelper驅(qū)動Cell), 另外, TableView的上拉刷新和下拉加載邏輯也是隸屬于Blog模塊的, 所以也在Helper中處理.
在頁面跳轉(zhuǎn)的邏輯中, 點(diǎn)擊跳轉(zhuǎn)的頁面是由Scene通過VCGeneratorBlock直接配置的, 所以也是解耦的(你也可以通過didSelectRowHandler之類的方式傳遞數(shù)據(jù)到Scene層, 由Scene做跳轉(zhuǎn), 是一樣的).
***, V(Cell)現(xiàn)在只暴露了Set方法供外部進(jìn)行設(shè)置, 所以和M(Blog)之間也是隔離的, 復(fù)用沒有問題.
這一系列過程都是自管理的, 將來如果Blog模塊會在另一個SceneX展示, 那么SceneX只需要新建一個BlogTableViewHelper, 然后調(diào)用一下helper.fetchData即可.
DraftTableViewHelper和BlogTableViewHelper邏輯類似, 就不貼了, 簡單貼一下UserInfo模塊的邏輯:
- @implementation UserInfoViewController
- + (instancetype)instanceUserId:(NSUInteger)userId {
- return [[UserInfoViewController alloc] initWithUserId:userId];
- }
- - (instancetype)initWithUserId:(NSUInteger)userId {
- // ...略
- [self addUI];
- // ...略
- }
- #pragma mark - Action
- - (void)onClickIconButton:(UIButton *)sender {
- [self.navigationController pushViewController:self.VCGenerator(self.user) animated:YES];
- }
- #pragma mark - Utils
- - (void)addUI {
- //各種UI初始化 各種布局
- self.userIconIV = [[UIImageView alloc] initWithFrame:CGRectZero];
- self.friendCountLabel = ...
- ...
- }
- - (void)fetchData {
- [[UserAPIManager new] fetchUserInfoWithUserId:self.userId completionHandler:^(NSError *error, id result) {
- if (error) {
- [self showErrorInView:self.view info:error.domain];
- } else {
- self.user = [User objectWithKeyValues:result];
- self.userIconIV.image = [UIImage imageWithURL:[NSURL URLWithString:self.user.url]];//數(shù)據(jù)格式化
- self.friendCountLabel.text = [NSString stringWithFormat:@"贊 %ld", self.user.friendCount];//數(shù)據(jù)格式化
- ...
- }
- }];
- }
- @end
UserInfoViewController除了比兩個TableViewHelper多個addUI的子控件布局方法, 其他邏輯大同小異, 也是自己管理的MVC, 也是只需要初始化即可在任何一個Scene中使用.
現(xiàn)在三個自管理模塊已經(jīng)建立完成, UserVC需要的只是根據(jù)自己的情況做相應(yīng)的拼裝布局即可, 就和搭積木一樣
作為業(yè)務(wù)場景的的Scene(UserVC)做的事情很簡單, 根據(jù)自身情況對三個模塊進(jìn)行配置(configuration), 布局(addUI), 然后通知各個模塊啟動(fetchData)就可以了, 因為每個模塊的展示和交互是自管理的, 所以Scene只需要負(fù)責(zé)和自身業(yè)務(wù)強(qiáng)相關(guān)的部分即可. 另外, 針對自身訪問的情況我們建立一個UserVC子類SelfVC, SelfVC做的也是類似的事情.
MVC到這就說的差不多了, 對比上面錯誤的MVC方式, 我們看看解決了哪些問題:
1.代碼復(fù)用: 三個小模塊的V(cell/userInfoView)對外只暴露Set方法, 對M甚至C都是隔離狀態(tài), 復(fù)用完全沒有問題. 三個大模塊的MVC也可以用于快速構(gòu)建相似的業(yè)務(wù)場景(大模塊的復(fù)用比小模塊會差一些, 下文我會說明).
2.代碼臃腫: 因為Scene大部分的邏輯和布局都轉(zhuǎn)移到了相應(yīng)的MVC中, 我們僅僅是拼裝MVC的便構(gòu)建了兩個不同的業(yè)務(wù)場景, 每個業(yè)務(wù)場景都能正常的進(jìn)行相應(yīng)的數(shù)據(jù)展示, 也有相應(yīng)的邏輯交互, 而完成這些東西, 加空格也就100行代碼左右(當(dāng)然, 這里我忽略了一下Scene的布局代碼).
3.易拓展性: 無論產(chǎn)品未來想加回收站還是防御塔, 我需要的只是新建相應(yīng)的MVC模塊, 加到對應(yīng)的Scene即可.
4.可維護(hù)性: 各個模塊間職責(zé)分離, 哪里出錯改哪里, 完全不影響其他模塊. 另外, 各個模塊的代碼其實并不算多, 哪一天即使寫代碼的人離職了, 接手的人根據(jù)錯誤提示也能快速定位出錯模塊.
5.易測試性: 很遺憾, 業(yè)務(wù)的初始化依然綁定在Scene的生命周期中, 而有些邏輯也仍然需要UI的點(diǎn)擊事件觸發(fā), 我們依然只能Command+R, 點(diǎn)點(diǎn)點(diǎn)…
- MVC的缺點(diǎn)
可以看到, 即使是標(biāo)準(zhǔn)的MVC架構(gòu)也并非***, 仍然有部分問題難以解決, 那么MVC的缺點(diǎn)何在? 總結(jié)如下:
1.過度的注重隔離: 這個其實MV(x)系列都有這缺點(diǎn), 為了實現(xiàn)V層的完全隔離, V對外只暴露Set方法, 一般情況下沒什么問題, 但是當(dāng)需要設(shè)置的屬性很多時, 大量重復(fù)的Set方法寫起來還是很累人的.
2.業(yè)務(wù)邏輯和業(yè)務(wù)展示強(qiáng)耦合: 可以看到, 有些業(yè)務(wù)邏輯(頁面跳轉(zhuǎn)/點(diǎn)贊/分享…)是直接散落在V層的, 這意味著我們在測試這些邏輯時, 必須首先生成對應(yīng)的V, 然后才能進(jìn)行測試. 顯然, 這是不合理的. 因為業(yè)務(wù)邏輯最終改變的是數(shù)據(jù)M, 我們的關(guān)注點(diǎn)應(yīng)該在M上, 而不是展示M的V.