雜談: MVC/MVP/MVVM

前言

本文爲回答一位朋友關於MVC/MVP/MVVM架構方面的疑問所寫, 旨在介紹iOS下MVC/MVP/MVVM三種架構的設計思路以及各自的優缺點. 全文約五千字, 預計花費閱讀時間20 - 30分鐘.

MVC

  • MVC的相關概念

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

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

  • MVC之消失的C層

2595746-29036860ea42fbf8.png

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//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];
}
//...略
1
2
3
4
5
6
7
8
//BlogCell
- (void)setBlog:(Blog)blog {
    _blog = blog;
     
    self.authorLabel.text = blog.blogAuthor;
    self.likeLebel.text = [NSString stringWithFormat:@"贊 %ld", blog.blogLikeCount];
    ...
}

小白很快寫完了代碼, Command+R一跑, 沒有問題, 心滿意足的做其他事情去了. 後來有一天, 產品要求這個業務需要改動, 用戶在看他人信息時是上圖中的頁面, 看自己的信息時, 多一個草稿箱的展示, 像這樣:

2595746-f98abdd550485408.png

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//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 = ...
}
1
2
3
4
//BlogCell
- (void)setBlog:(Blog)blog {
    ...同上
}

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

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

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

2595746-e8770c633cb5cc74.png

通過這張圖可以發現, 用戶信息頁面作爲業務場景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應該是這個樣子的:

2595746-2d7ea66f64955f87.png

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

1
2
3
4
5
6
7
8
@interface BlogTableViewHelper : NSObject(UITableViewDelegate, UITableViewDataSource)(識別問題此處圓括號替換尖括號)
 
+ (instancetype)helperWithTableView:(UITableView *)tableView userId:(NSUInteger)userId;
 
- (void)fetchDataWithCompletionHandler:(NetworkTaskCompletionHander)completionHander;
- (void)setVCGenerator:(ViewControllerGenerator)VCGenerator;
 
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@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
1
2
3
4
5
6
7
8
9
10
11
12
13
@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
1
2
3
4
5
6
7
8
9
10
@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模塊的邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@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需要的只是根據自己的情況做相應的拼裝佈局即可, 就和搭積木一樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
@interface UserViewController ()
 
@property (assign, nonatomic) NSUInteger userId;
@property (strong, nonatomic) UserInfoViewController *userInfoVC;
 
@property (strong, nonatomic) UITableView *blogTableView;
@property (strong, nonatomic) BlogTableViewHelper *blogTableViewHelper;
 
@end
 
@interface SelfViewController : UserViewController
 
@property (strong, nonatomic) UITableView *draftTableView;
@property (strong, nonatomic) DraftTableViewHelper *draftTableViewHelper;
 
@end
 
#pragma mark - UserViewController
 
@implementation UserViewController
 
+ (instancetype)instanceWithUserId:(NSUInteger)userId {
    if (userId == LoginUserId) {
        return [[SelfViewController alloc] initWithUserId:userId];
    else {
        return [[UserViewController alloc] initWithUserId:userId];
    }
}
 
- (void)viewDidLoad {
    [super viewDidLoad];
 
    [self addUI];
 
    [self configuration];
 
    [self fetchData];
}
 
#pragma mark - Utils(UserViewController)
 
- (void)addUI {
 
    //這裏只是表達一下意思 具體的layout邏輯肯定不是這麼簡單的
    self.userInfoVC = [UserInfoViewController instanceWithUserId:self.userId];
    self.userInfoVC.view.frame = CGRectZero;
    [self.view addSubview:self.userInfoVC.view];
    [self.view addSubview:self.blogTableView = [[UITableView alloc] initWithFrame:CGRectZero style:0]];
}
 
- (void)configuration {
 
    self.title = @"用戶詳情";
//    ...其他設置
 
    [self.userInfoVC setVCGenerator:^UIViewController *(id params) {
        return [UserDetailViewController instanceWithUser:params];
    }];
 
    self.blogTableViewHelper = [BlogTableViewHelper helperWithTableView:self.blogTableView userId:self.userId];
    [self.blogTableViewHelper setVCGenerator:^UIViewController *(id params) {
        return [BlogDetailViewController instanceWithBlog:params];
    }];
}
 
- (void)fetchData {
 
    [self.userInfoVC fetchData];//userInfo模塊不需要任何頁面加載提示
    [HUD show];//blog模塊可能就需要HUD
    [self.blogTableViewHelper fetchDataWithcompletionHandler:^(NSError *error, id result) {
      [HUD hide];
    }];
}
 
@end
 
#pragma mark - SelfViewController
 
@implementation SelfViewController
- (void)viewDidLoad {
    [super viewDidLoad];
 
    [self addUI];
 
    [self configuration];
 
    [self fetchData];
}
 
#pragma mark - Utils(SelfViewController)
 
- (void)addUI {
    [super addUI];
 
    [self.view addSubview:switchButton];//特有部分...
    //...各種設置
    [self.view addSubview:self.draftTableView = [[UITableView alloc] initWithFrame:CGRectZero style:0]];
}
 
- (void)configuration {
    [super configuration];
 
    self.draftTableViewHelper = [DraftTableViewHelper helperWithTableView:self.draftTableView userId:self.userId];
    [self.draftTableViewHelper setVCGenerator:^UIViewController *(id params) {
        return [DraftDetailViewController instanceWithDraft:params];
    }];
}
 
- (void)fetchData {
    [super fetchData];
    [self.draftTableViewHelper fetchData];
}
 
@end

作爲業務場景的的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.

MVP

MVC的缺點在於並沒有區分業務邏輯和業務展示, 這對單元測試很不友好. MVP針對以上缺點做了優化, 它將業務邏輯和業務展示也做了一層隔離, 對應的就變成了MVCP. M和V功能不變, 原來的C現在只負責佈局, 而所有的邏輯全都轉移到了P層.

對應關係如圖所示:

011.png

業務場景沒有變化, 依然是展示三種數據, 只是三個MVC替換成了三個MVP(圖中我只畫了Blog模塊), UserVC負責配置三個MVP(新建各自的VP, 通過VP建立C, C會負責建立VP之間的綁定關係), 並在合適的時機通知各自的P層(之前是通知C層)進行數據獲取, 各個P層在獲取到數據後進行相應處理, 處理完成後會通知綁定的View數據有所更新, V收到更新通知後從P獲取格式化好的數據進行頁面渲染, UserVC最後將已經渲染好的各個View進行佈局即可. 另外, V層C層不再處理任何業務邏輯, 所有事件觸發全部調用P層的相應命令, 具體到代碼中如下:

1
2
3
4
5
6
7
8
9
@interface BlogPresenter : NSObject
 
+ (instancetype)instanceWithUserId:(NSUInteger)userId;
 
- (NSArray *)allDatas;//業務邏輯移到了P層 和業務相關的M也跟着到了P層
- (void)refreshUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;
- (void)loadMoreUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;
 
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@interface BlogPresenter()
 
@property (assign, nonatomic) NSUInteger userId;
@property (strong, nonatomic) NSMutableArray *blogs;
@property (strong, nonatomic) UserAPIManager *apiManager;
 
@end
 
@implementation BlogPresenter
 
+ (instancetype)instanceWithUserId:(NSUInteger)userId {
    return [[BlogPresenter alloc] initWithUserId:userId];
}
 
- (instancetype)initWithUserId:(NSUInteger)userId {
    if (self = [super init]) {
        self.userId = userId;
        self.apiManager = [UserAPIManager new];
        //...略
    }
}
 
#pragma mark - Interface
 
- (NSArray *)allDatas {
    return self.blogs;
}
//提供給外層調用的命令
- (void)refreshUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler {
 
    [self.apiManager refreshUserBlogsWithUserId:self.userId completionHandler:^(NSError *error, id result) {
        if (!error) {
 
            [self.blogs removeAllObjects];//清空之前的數據
            for (Blog *blog in result) {
                [self.blogs addObject:[BlogCellPresenter presenterWithBlog:blog]];
            }
        }
        completionHandler ? completionHandler(error, result) : nil;
    }];
}
//提供給外層調用的命令
- (void)loadMoreUserBlogsWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler {
    [self.apiManager loadMoreUserBlogsWithUserId:self.userId completionHandler...]
}
 
@end
1
2
3
4
5
6
7
8
9
10
@interface BlogCellPresenter : NSObject
 
+ (instancetype)presenterWithBlog:(Blog *)blog;
 
- (NSString *)authorText;
- (NSString *)likeCountText;
 
- (void)likeBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;
- (void)shareBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@implementation BlogCellPresenter
 
- (NSString *)likeCountText {
    return [NSString stringWithFormat:@"贊 %ld", self.blog.likeCount];
}
 
- (NSString *)authorText {
    return [NSString stringWithFormat:@"作者姓名: %@", self.blog.authorName];
}
//    ...略
- (void)likeBlogWithCompletionHandler:(NetworkTaskCompletionHander)completionHandler {
 
    [[UserAPIManager new] likeBlogWithBlogId:self.blogId userId:self.userId completionHandler:^(NSError *error, id result) {
        if (error) {
            //do fail
        else {
            //do success
            self.blog.likeCount += 1;
        }
        completionHandler ? completionHandler(error, result) : nil;
    }];
}
//    ...略
@end

BlogPresenter和BlogCellPresenter分別作爲BlogViewController和BlogCell的P層, 其實就是一系列業務邏輯的集合. BlogPresenter負責獲取Blogs原始數據並通過這些原始數據構造BlogCellPresenter, 而BlogCellPresenter提供格式化好的各種數據以供Cell渲染, 另外, 點贊和分享的業務現在也轉移到了這裏.

業務邏輯被轉移到了P層, 此時的V層只需要做兩件事:

1.監聽P層的數據更新通知, 刷新頁面展示.

2.在點擊事件觸發時, 調用P層的對應方法, 並對方法執行結果進行展示.

1
2
3
@interface BlogCell : UITableViewCell
@property (strong, nonatomic) BlogCellPresenter *presenter;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@implementation BlogCell
 
- (void)setPresenter:(BlogCellPresenter *)presenter {
    _presenter = presenter;
    //從Presenter獲取格式化好的數據進行展示
    self.authorLabel.text = presenter.authorText;
    self.likeCountLebel.text = presenter.likeCountText;
//    ...略
}
 
#pragma mark - Action
 
- (void)onClickLikeButton:(UIButton *)sender {
    [self.presenter likeBlogWithCompletionHandler:^(NSError *error, id result) {
        if (!error) {//頁面刷新
            self.likeCountLebel.text = self.presenter.likeCountText;
        }
//        ...略
    }];
}
 
@end

而C層做的事情就是佈局和PV之間的綁定(這裏可能不太明顯, 因爲BlogVC裏面的佈局代碼是TableViewDataSource, PV綁定的話, 因爲我偷懶用了Block做通知回調, 所以也不太明顯, 如果是Protocol回調就很明顯了), 代碼如下:

1
2
3
4
5
6
7
@interface BlogViewController : NSObject
 
+ (instancetype)instanceWithTableView:(UITableView *)tableView presenter:(BlogPresenter)presenter;
 
- (void)setDidSelectRowHandler:(void (^)(Blog *))didSelectRowHandler;
- (void)fetchDataWithCompletionHandler:(NetworkCompletionHandler)completionHandler;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@interface BlogViewController ()(UITableViewDataSource, UITabBarDelegate, BlogView)(識別問題此處圓括號替換尖括號使用)
 
@property (weak, nonatomic) UITableView *tableView;
@property (strong, nonatomic) BlogPresenter presenter;
@property (copy, nonatomic) void(^didSelectRowHandler)(Blog *);
 
@end
 
@implementation BlogViewController
 
+ (instancetype)instanceWithTableView:(UITableView *)tableView presenter:(BlogPresenter)presenter {
    return [[BlogViewController alloc] initWithTableView:tableView presenter:presenter];
}
 
- (instancetype)initWithTableView:(UITableView *)tableView presenter:(BlogPresenter)presenter {
    if (self = [super init]) {
 
        self.presenter = presenter;
        self.tableView = tableView;
        tableView.delegate = self;
        tableView.dataSource = self;
 
        __weak typeof(self) weakSelf = self;
        [tableView registerClass:[BlogCell class] forCellReuseIdentifier:BlogCellReuseIdentifier];
        tableView.header = [MJRefreshAnimationHeader headerWithRefreshingBlock:^{//下拉刷新
            [weakSelf.presenter refreshUserBlogsWithCompletionHandler:^(NSError *error, id result) {
                [weakSelf.tableView.header endRefresh];
                if (!error) {
                    [weakSelf.tableView reloadData];
                }
                //...略
            }];
        }];
        tableView.footer = [MJRefreshAnimationFooter headerWithRefreshingBlock:^{//上拉加載
            [weakSelf.presenter loadMoreUserBlogsWithCompletionHandler:^(NSError *error, id result) {
                [weakSelf.tableView.footer endRefresh];
                if (!error) {
                    [weakSelf.tableView reloadData];
                }
                //...略
            }];
        }];
    }
    return self;
}
 
#pragma mark - Interface
 
- (void)fetchDataWithCompletionHandler:(NetworkCompletionHandler)completionHandler {
    [self.presenter refreshUserBlogsWithCompletionHandler:^(NSError *error, id result) {
        if (error) {
            //show error info
        else {
            [self.tableView reloadData];
        }
        completionHandler ? completionHandler(error, result) : nil;
    }];
}
 
#pragma mark - UITableViewDataSource && Delegate
 
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.presenter.allDatas.count;
}
 
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
 
    BlogCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogCellReuseIdentifier];
    BlogCellPresenter *cellPresenter = self.presenter.allDatas[indexPath.row];
    cell.present = cellPresenter;
    return cell;
}
 
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
     self.didSelectRowHandler ? self.didSelectRowHandler(self.presenter.allDatas[indexPath.row]) : nil;
}
 
@end

BlogViewController現在不再負責實際的數據獲取邏輯, 數據獲取直接調用Presenter的相應接口, 另外, 因爲業務邏輯也轉移到了Presenter, 所以TableView的佈局用的也是Presenter.allDatas. 至於Cell的展示, 我們替換了原來大量的Set方法, 讓Cell自己根據綁定的CellPresenter做展示. 畢竟現在邏輯都移到了P層, V層要做相應的交互也必須依賴對應的P層命令, 好在V和M仍然是隔離的, 只是和P耦合了, P層是可以隨意替換的, M顯然不行, 這是一種折中.

最後是Scene, 它的變動不大, 只是替換配置MVC爲配置MVP, 另外數據獲取也是走P層, 不走C層了(然而代碼裏面並不是這樣的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void)configuration {
 
//    ...其他設置
    BlogPresenter *blogPresenter = [BlogPresenter instanceWithUserId:self.userId];
    self.blogViewController = [BlogViewController instanceWithTableView:self.blogTableView presenter:blogPresenter];
    [self.blogViewController setDidSelectRowHandler:^(Blog *blog) {
        [self.navigationController pushViewController:[BlogDetailViewController instanceWithBlog:blog] animated:YES];
    }];
//    ...略
}
 
- (void)fetchData {
 
//        ...略
    [self.userInfoVC fetchData];
    [HUD show];
    [self.blogViewController fetchDataWithCompletionHandler:^(NSError *error, id result) {
        [HUD hide];
    }];
//還是因爲懶, 用了Block走C層轉發會少寫一些代碼, 如果是Protocol或者KVO方式就會用self.blogViewController.presenter了
//不過沒有關係, 因爲我們替換MVC爲MVP是爲了解決單元測試的問題, 現在的用法完全不影響單元測試, 只是和概念不符罷了.
//        ...略
}

上面的例子中其實有一個問題, 即我們假定: 所有的事件都是由V層主動發起且一次性的. 這其實是不成立的, 舉個簡單的例子: 類似微信語音聊天之類的頁面, 點擊語音Cell開始播放, Cell展示播放動畫, 播放完成動畫停止, 然後播放下一條語音.

在這個播放場景中, 如果CellPresenter還是像上面一樣僅僅提供一個playWithCompletionHandler的接口是行不通的. 因爲播放完成後回調肯定是在C層, C層在播放完成後會發現此時執行播放命令的CellPresenter無法通知Cell停止動畫, 即事件的觸發不是一次性的. 另外, 在播放完成後, C層遍歷到下一個待播放CellPresenterX調用播放接口時, CellPresenterX因爲並不知道它對應的Cell是誰, 當然也就無法通知Cell開始動畫, 即事件的發起者並不一定是V層.

針對這些非一次性或者其他層發起事件, 處理方法其實很簡單, 在CellPresenter加個Block屬性就行了, 因爲是屬性, Block可以多次回調, 另外Block還可以捕獲Cell, 所以也不擔心找不到對應的Cell. 大概這樣:

1
2
3
4
5
6
@interface VoiceCellPresenter : NSObject
 
@property (copy, nonatomic) void(^didUpdatePlayStateHandler)(NSUInteger);
 
- (NSURL *)playURL;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@implementation VoiceCell
 
- (void)setPresenter:(VoiceCellPresenter *)presenter {
    _presenter = presenter;
 
    if (!presenter.didUpdatePlayStateHandler) {
        __weak typeof(self) weakSelf = self;
        [presenter setDidUpdatePlayStateHandler:^(NSUInteger playState) {
            switch (playState) {
                case Buffering: weakSelf.playButton... break;
                case Playing: weakSelf.playButton... break;
                case Paused: weakSelf.playButton... break;
            }
        }];
    }
}

播放的時候, VC只需要保持一下CellPresenter, 然後傳入相應的playState調用didUpdatePlayStateHandler就可以更新Cell的狀態了.

當然, 如果是Protocol的方式進行的VP綁定, 那麼做這些事情就很平常了, 就不寫了.

MVP大概就是這個樣子了, 相對於MVC, 它其實只做了一件事情, 即分割業務展示和業務邏輯. 展示和邏輯分開後, 只要我們能保證V在收到P的數據更新通知後能正常刷新頁面, 那麼整個業務就沒有問題. 因爲V收到的通知其實都是來自於P層的數據獲取/更新操作, 所以我們只要保證P層的這些操作都是正常的就可以了. 即我們只用測試P層的邏輯, 不必關心V層的情況.

MVVM

MVP其實已經是一個很好的架構, 幾乎解決了所有已知的問題, 那麼爲什麼還會有MVVM呢?

仍然是舉例說明, 假設現在有一個Cell, 點擊Cell上面的關注按鈕可以是加關注, 也可以是取消關注, 在取消關注時, SceneA要求先彈窗詢問, 而SceneB則不做彈窗, 那麼此時的取消關注操作就和業務場景強關聯, 所以這個接口不可能是V層直接調用, 會上升到Scene層.具體到代碼中, 大概這個樣子:

1
2
3
4
5
6
7
@interface UserCellPresenter : NSObject
 
@property (copy, nonatomic) void(^followStateHander)(BOOL isFollowing);
@property (assign, nonatomic) BOOL isFollowing;
 
- (void)follow;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@implementation UserCellPresenter
 
- (void)follow {
    if (!self.isFollowing) {//未關注 去關注
//        follow user
    else {//已關注 則取消關注
 
        self.followStateHander ? self.followStateHander(YES) : nil;//先通知Cell顯示follow狀態
        [[FollowAPIManager new] unfollowWithUserId:self.userId completionHandler:^(NSError *error, id result) {
            if (error) {
                self.followStateHander ? self.followStateHander(NO) : nil;//follow失敗 狀態回退
            } eles {
                self.isFollowing = YES;
            }
            //...略
        }];
    }
}
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@implementation UserCell
 
- (void)setPresenter:(UserCellPresenter *)presenter {
    _presenter = presenter;
 
    if (!_presenter.followStateHander) {
        __weak typeof(self) weakSelf = self;
        [_presenter setFollowStateHander:^(BOOL isFollowing) {
            [weakSelf.followStateButton setImage:isFollowing ? : ...];
        }];
    }
}
 
- (void)onClickFollowButton:(UIButton *)button {//將關注按鈕點擊事件上傳
    [self routeEvent:@"followEvent" userInfo:@{@"presenter" : self.presenter}];
}
 
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation FollowListViewController
 
//攔截點擊事件 判斷後確認是否執行事件
- (void)routeEvent:(NSString *)eventName userInfo:(NSDictionary *)userInfo {
 
    if ([eventName isEqualToString:@"followEvent"]) {
        UserCellPresenter *presenter = userInfo[@"presenter"];
        [self showAlertWithTitle:@"提示" message:@"確認取消對他的關注嗎?" cancelHandler:nil confirmHandler: ^{
            [presenter follow];
        }];
    }
}
 
@end
1
2
3
4
5
6
7
@implementation UIResponder (Router)
 
//沿着響應者鏈將事件上傳 事件最終被攔截處理 或者 無人處理直接丟棄
- (void)routeEvent:(NSString *)eventName userInfo:(NSDictionary *)userInfo {
    [self.nextResponder routeEvent:eventName userInfo:userInfo];
}
@end

Block方式看起來略顯繁瑣, 我們換到Protocol看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@protocol UserCellPresenterCallBack (NSObject)(識別問題此處圓括號替換尖括號)
 
- (void)userCellPresenterDidUpdateFollowState:(BOOL)isFollowing;
 
@end
 
@interface UserCellPresenter : NSObject
 
@property (weak, nonatomic) id(UserCellPresenterCallBack) view(識別問題此處圓括號替換尖括號);
@property (assign, nonatomic) BOOL isFollowing;
 
- (void)follow;
 
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@implementation UserCellPresenter
 
- (void)follow {
    if (!self.isFollowing) {//未關注 去關注
//        follow user
    else {//已關注 則取消關注
 
        BOOL isResponse = [self.view respondsToSelector:@selector(userCellPresenterDidUpdateFollowState)];
        isResponse ? [self.view userCellPresenterDidUpdateFollowState:YES] : nil;
        [[FollowAPIManager new] unfollowWithUserId:self.userId completionHandler:^(NSError *error, id result) {
            if (error) {
                isResponse ? [self.view userCellPresenterDidUpdateFollowState:NO] : nil;
            } eles {
                self.isFollowing = YES;
            }
            //...略
        }];
    }
}
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
@implementation UserCell
 
- (void)setPresenter:(UserCellPresenter *)presenter {
 
    _presenter = presenter;
    _presenter.view = self;
}
 
#pragma mark - UserCellPresenterCallBack
 
- (void)userCellPresenterDidUpdateFollowState:(BOOL)isFollowing {
    [self.followStateButton setImage:isFollowing ? : ...];
}

除去Route和VC中Alert之類的代碼, 可以發現無論是Block方式還是Protocol方式因爲需要對頁面展示和業務邏輯進行隔離, 代碼上饒了一小圈, 無形中增添了不少的代碼量, 這裏僅僅只是一個事件就這樣, 如果是多個呢? 那寫起來真是蠻傷的...

仔細看一下上面的代碼就會發現, 如果我們繼續添加事件, 那麼大部分的代碼都是在做一件事情: P層將數據更新通知到V層. Block方式會在P層添加很多屬性, 在V層添加很多設置Block邏輯. 而Protocol方式雖然P層只添加了一個屬性, 但是Protocol裏面的方法卻會一直增加, 對應的V層也就需要增加的方法實現.

問題既然找到了, 那就試着去解決一下吧, OC中能夠實現兩個對象間的低耦合通信, 除了Block和Protocol, 一般都會想到KVO. 我們看看KVO在上面的例子有何表現:

1
2
3
4
5
6
@interface UserCellViewModel : NSObject
 
@property (assign, nonatomic) BOOL isFollowing;
 
- (void)follow;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation UserCellViewModel
 
- (void)follow {
    if (!self.isFollowing) {//未關注 去關注
//        follow user
    else {//已關注 則取消關注
 
        self.isFollowing = YES;//先通知Cell顯示follow狀態
        [[FollowAPIManager new] unfollowWithUserId:self.userId completionHandler:^(NSError *error, id result) {
            if (error) { self.isFollowing = NO; }//follow失敗 狀態回退
            //...略
        }];
    }
}
@end
1
2
3
4
5
6
7
8
@implementation UserCell
- (void)awakeFromNib {
    @weakify(self);
    [RACObserve(self, viewModel.isFollowing) subscribeNext:^(NSNumber *isFollowing) {
        @strongify(self);
        [self.followStateButton setImage:[isFollowing boolValue] ? : ...];
    };
}

代碼大概少了一半左右, 另外, 邏輯讀起來也清晰多了, Cell觀察綁定的ViewModel的isFollowing狀態, 並在狀態改變時, 更新自己的展示.

三種數據通知方式簡單一比對, 相信哪種方式對程序員更加友好, 大家都心裏有數, 就不做贅述了.

現在大概一提到MVVM就會想到RAC, 但這兩者其實並沒有什麼聯繫, 對於MVVM而言RAC只是提供了優雅安全的數據綁定方式, 如果不想學RAC, 自己搞個KVOHelper之類的東西也是可以的. 另外 ,RAC的魅力其實在於函數式響應式編程, 我們不應該僅僅將它侷限於MVVM的應用, 日常的開發中也應該多使用使用的.

關於MVVM, 我想說的就是這麼多了, 因爲MVVM其實只是MVP的綁定進化體, 除去數據綁定方式, 其他的和MVP如出一轍, 只是可能呈現方式是Command/Signal而不是CompletionHandler之類的, 故不做贅述.

最後做個簡單的總結吧:

1.MVC作爲老牌架構, 優點在於將業務場景按展示數據類型劃分出多個模塊, 每個模塊中的C層負責業務邏輯和業務展示, 而M和V應該是互相隔離的以做重用, 另外每個模塊處理得當也可以作爲重用單元. 拆分在於解耦, 順便做了減負, 隔離在於重用, 提升開發效率. 缺點是沒有區分業務邏輯和業務展示, 對單元測試不友好.

2.MVP作爲MVC的進階版, 提出區分業務邏輯和業務展示, 將所有的業務邏輯轉移到P層, V層接受P層的數據更新通知進行頁面展示. 優點在於良好的分層帶來了友好的單元測試, 缺點在於分層會讓代碼邏輯優點繞, 同時也帶來了大量的代碼工作, 對程序員不夠友好.

3.MVVM作爲集大成者, 通過數據綁定做數據更新, 減少了大量的代碼工作, 同時優化了代碼邏輯, 只是學習成本有點高, 對新手不夠友好.

4.MVP和MVVM因爲分層所以會建立MVC兩倍以上的文件類, 需要良好的代碼管理方式.

5.在MVP和MVVM中, V和P或者VM之間理論上是多對多的關係, 不同的佈局在相同的邏輯下只需要替換V層, 而相同的佈局不同的邏輯只需要替換P或者VM層. 但實際開發中P或者VM往往因爲耦合了V層的展示邏輯退化成了一對一關係(比如SceneA中需要顯示"xxx+Name", VM就將Name格式化爲"xxx + Name". 某一天SceneB也用到這個模塊, 所有的點擊事件和頁面展示都一樣, 只是Name展示爲"yyy + Name", 此時的VM因爲耦合SceneA的展示邏輯, 就顯得比較尷尬), 針對此類情況, 通常有兩種辦法, 一種是在VM層加狀態進而判斷輸出狀態, 一種是在VM層外再加一層FormatHelper. 前者可能因爲狀態過多顯得代碼難看, 後者雖然比較優雅且拓展性高, 但是過多的分層在數據還原時就略顯笨拙, 大家應該按需選擇.

這裏隨便瞎扯一句, 有些文章上來就說MVVM是爲了解決C層臃腫, MVC難以測試的問題, 其實並不是這樣的. 按照架構演進順序來看, C層臃腫大部分是沒有拆分好MVC模塊, 好好拆分就行了, 用不着MVVM. 而MVC難以測試也可以用MVP來解決, 只是MVP也並非完美, 在VP之間的數據交互太繁瑣, 所以才引出了MVVM. 當MVVM這個完全體出現以後, 我們從結果看起源, 發現它做了好多事情, 其實並不是, 它的前輩們付出的努力也並不少!

  • 架構那麼多, 日常開發中到底該如何選擇?

不管是MVC, MVP, MVVM還是MVXXX, 最終的目的在於服務於人, 我們注重架構, 注重分層都是爲了開發效率, 說到底還是爲了開心. 所以, 在實際開發中不應該拘泥於某一種架構, 根據實際項目出發, 一般普通的MVC就能應對大部分的開發需求, 至於MVP和MVVM, 可以嘗試, 但不要強制.

總之, 希望大家能做到: 設計時, 心中有數. 擼碼時, 開心就好.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章