雜談: MVC/MVP/MVVM (一)

前言

本文為回答一位朋友關于MVC/MVP/MVVM架構方面的疑問所寫, 旨在介紹iOS下MVC/MVP/MVVM三種架構的設計思路以及各自的優缺點.

MVC

MVC的相關概念

MVC最早存在于桌面程序中的, M是指業務數據, V是指用戶界面, C則是控制器. 在具體的業務場景中, C作為M和V之間的連接, 負責獲取輸入的業務數據, 然后將處理后的數據輸出到界面上做相應展示, 另外, 在數據有所更新時, C還需要及時提交相應更新到界面展示. 在上述過程中, 因為M和V之間是完全隔離的, 所以在業務場景切換時, 通常只需要替換相應的C, 復用已有的M和V便可快速搭建新的業務場景. MVC因其復用性, 大大提高了開發效率, 現已被廣泛應用在各端開發中.

概念過完了, 下面來看看, 在具體的業務場景中MVC/MVP/MVVM都是如何表現的.

MVC之消失的C層

上圖中的頁面(業務場景)或者類似頁面相信大家做過不少, 各個程序員的具體實現方式可能各不一樣, 這里說說我所看到的部分程序員的寫法:

//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一跑, 沒有問題, 心滿意足的做其他事情去了. 后來有一天, 產品要求這個業務需要改動, 用戶在看他人信息時是上圖中的頁面, 看自己的信息時, 多一個草稿箱的展示, 像這樣:

于是小白將代碼改成這樣:

//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 {

...同上

}

后來啊, 產品覺得用戶看自己的頁面再加個回收站什么的會很好, 于是程序員又加上一段代碼邏輯 , 再后來…

隨著需求的變更, UserVC變得越來越臃腫, 越來越難以維護, 拓展性和測試性也極差. 程序員也發現好像代碼寫得有些問題, 但是問題具體出在哪里? 難道這不是MVC嗎?

我們將上面的過程用一張圖來表示:


通過這張圖可以發現, 用戶信息頁面作為業務場景Scene需要展示多種數據M(Blog/Draft/UserInfo), 所以對應的有多個View(blogTableView/draftTableView/image…), 但是, 每個MV之間并沒有一個連接層C, 本來應該分散到各個C層處理的邏輯全部被打包丟到了Scene這一個地方處理, 也就是M-C-V變成了MM…-Scene-…VV, C層就這樣莫名其妙的消失了.

另外, 作為V的兩個cell直接耦合了M(blog/draft), 這意味著這兩個V的輸入被綁死到了相應的M上, 復用無從談起.

最后, 針對這個業務場景的測試異常麻煩, 因為業務初始化和銷毀被綁定到了VC的生命周期上, 而相應的邏輯也關聯到了和View的點擊事件, 測試只能Command+R, 點點點…

正確的MVC使用姿勢

也許是UIViewController的類名給新人帶來了迷惑, 讓人誤以為VC就一定是MVC中的C層, 又或許是Button, Label之類的View太過簡單完全不需要一個C層來配合, 總之, 我工作以來經歷的項目中見過太多這樣的”MVC”. 那么, 什么才是正確的MVC使用姿勢呢?

仍以上面的業務場景舉例, 正確的MVC應該是這個樣子的:

UserVC作為業務場景, 需要展示三種數據, 對應的就有三個MVC, 這三個MVC負責各自模塊的數據獲取, 數據處理和數據展示, 而UserVC需要做的就是配置好這三個MVC, 并在合適的時機通知各自的C層進行數據獲取, 各個C層拿到數據后進行相應處理, 處理完成后渲染到各自的View上, UserVC最后將已經渲染好的各個View進行布局即可, 具體到代碼中如下:

@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;

//...各種設置

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)構成, 這里有點特殊, blogs里面裝的不是M, 而是Cell的C層CellHelper, 這是因為Blog的MVC其實又是由多個更小的MVC組成的. M和V沒什么好說的, 主要說一下作為C的TableVIewHelper做了什么.

實際開發中, 各個模塊的View可能是在Scene對應的Storyboard中新建并布局的, 此時就不用各個模塊自己建立View了(比如這里的BlogTableViewHelper), 讓Scene傳到C層進行管理就行了, 當然, 如果你是純代碼的方式, 那View就需要相應模塊自行建立了(比如下文的UserInfoViewController), 這個看自己的意愿, 無傷大雅.

BlogTableViewHelper對外提供獲取數據和必要的構造方法接口, 內部根據自身情況進行相應的初始化.

當外部調用fetchData的接口后, Helper就會啟動獲取數據邏輯, 因為數據獲取前后可能會涉及到一些頁面展示(HUD之類的), 而具體的展示又是和Scene直接相關的(有的Scene展示的是HUD有的可能展示的又是一種樣式或者根本不展示), 所以這部分會以CompletionHandler的形式交由Scene自己處理.

在Helper內部, 數據獲取失敗會展示相應的錯誤頁面, 成功則建立更小的MVC部分并通知其展示數據(也就是通知CellHelper驅動Cell), 另外, TableView的上拉刷新和下拉加載邏輯也是隸屬于Blog模塊的, 所以也在Helper中處理.

在頁面跳轉的邏輯中, 點擊跳轉的頁面是由Scene通過VCGeneratorBlock直接配置的, 所以也是解耦的(你也可以通過didSelectRowHandler之類的方式傳遞數據到Scene層, 由Scene做跳轉, 是一樣的).

最后, V(Cell)現在只暴露了Set方法供外部進行設置, 所以和M(Blog)之間也是隔離的, 復用沒有問題.

這一系列過程都是自管理的, 將來如果Blog模塊會在另一個SceneX展示, 那么SceneX只需要新建一個BlogTableViewHelper, 然后調用一下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]];//數據格式化

self.friendCountLabel.text = [NSString stringWithFormat:@"贊 %ld", self.user.friendCount];//數據格式化

...

}

}];

}

@end

UserInfoViewController除了比兩個TableViewHelper多個addUI的子控件布局方法, 其他邏輯大同小異, 也是自己管理的MVC, 也是只需要初始化即可在任何一個Scene中使用.

現在三個自管理模塊已經建立完成, UserVC需要的只是根據自己的情況做相應的拼裝布局即可, 就和搭積木一樣

作為業務場景的的Scene(UserVC)做的事情很簡單, 根據自身情況對三個模塊進行配置(configuration), 布局(addUI), 然后通知各個模塊啟動(fetchData)就可以了, 因為每個模塊的展示和交互是自管理的, 所以Scene只需要負責和自身業務強相關的部分即可. 另外, 針對自身訪問的情況我們建立一個UserVC子類SelfVC, SelfVC做的也是類似的事情.

MVC到這就說的差不多了, 對比上面錯誤的MVC方式, 我們看看解決了哪些問題:

1.代碼復用: 三個小模塊的V(cell/userInfoView)對外只暴露Set方法, 對M甚至C都是隔離狀態, 復用完全沒有問題. 三個大模塊的MVC也可以用于快速構建相似的業務場景(大模塊的復用比小模塊會差一些, 下文我會說明).

2.代碼臃腫: 因為Scene大部分的邏輯和布局都轉移到了相應的MVC中, 我們僅僅是拼裝MVC的便構建了兩個不同的業務場景, 每個業務場景都能正常的進行相應的數據展示, 也有相應的邏輯交互, 而完成這些東西, 加空格也就100行代碼左右(當然, 這里我忽略了一下Scene的布局代碼).

3.易拓展性: 無論產品未來想加回收站還是防御塔, 我需要的只是新建相應的MVC模塊, 加到對應的Scene即可.

4.可維護性: 各個模塊間職責分離, 哪里出錯改哪里, 完全不影響其他模塊. 另外, 各個模塊的代碼其實并不算多, 哪一天即使寫代碼的人離職了, 接手的人根據錯誤提示也能快速定位出錯模塊.

5.易測試性: 很遺憾, 業務的初始化依然綁定在Scene的生命周期中, 而有些邏輯也仍然需要UI的點擊事件觸發, 我們依然只能Command+R, 點點點…

MVC的缺點

可以看到, 即使是標準的MVC架構也并非完美, 仍然有部分問題難以解決, 那么MVC的缺點何在? 總結如下:

1.過度的注重隔離: 這個其實MV(x)系列都有這缺點, 為了實現V層的完全隔離, V對外只暴露Set方法, 一般情況下沒什么問題, 但是當需要設置的屬性很多時, 大量重復的Set方法寫起來還是很累人的.

2.業務邏輯和業務展示強耦合: 可以看到, 有些業務邏輯(頁面跳轉/點贊/分享…)是直接散落在V層的, 這意味著我們在測試這些邏輯時, 必須首先生成對應的V, 然后才能進行測試. 顯然, 這是不合理的. 因為業務邏輯最終改變的是數據M, 我們的關注點應該在M上, 而不是展示M的V.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,106評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,441評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,211評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,736評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,475評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,834評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,829評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,009評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,559評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,306評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,516評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,038評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,728評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,132評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,443評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,249評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,484評論 2 379

推薦閱讀更多精彩內容

  • 前言 本文為回答一位朋友關于MVC/MVP/MVVM架構方面的疑問所寫, 旨在介紹iOS下MVC/MVP/MVVM...
    L劉慶閱讀 375評論 0 0
  • 原文 前言 本文為回答一位朋友關于MVC/MVP/MVVM架構方面的疑問所寫, 旨在介紹iOS下MVC/MVP/M...
    無灃閱讀 721評論 1 2
  • MVP MVC的缺點在于并沒有區分業務邏輯和業務展示, 這對單元測試很不友好. MVP針對以上缺點做了優化, 它將...
    _VisitorsZsl閱讀 468評論 0 3
  • MVP MVC的缺點在于并沒有區分業務邏輯和業務展示, 這對單元測試很不友好. MVP針對以上缺點做了優化, 它將...
    L劉慶閱讀 359評論 0 0
  • //聯系人:石虎QQ: 1224614774昵稱:嗡嘛呢叭咪哄 MVC MVC的相關概念 MVC最早存在于桌面程序...
    石虎132閱讀 2,186評論 0 9