實踐乾貨!猿題庫 iOS 客戶端架構設計

Kaitiren發表於2016-01-10

猿題庫是一個擁有數千萬使用者的創業公司,從2013年題庫專案起步到2015年,團隊保持了極高的生產效率,使我們的產品完成了五個大版本和數十個小版本的高速迭代。在如此快速的開發過程中,如何保證程式碼的質量,降低後期維護的成本,以及為專案越來越快的版本迭代速度提供支援,成為了我們關注的重要問題。這篇文章將闡明我們在猿題庫 iOS 客戶端的架構設計。

MVC

MVC,Model-View-Controller,我們從這個古老而經典的設計模式入手。採用 MVC 這個架構的最大的優點在於其概念簡單,易於理解,幾乎任何一個程式設計師都會有所瞭解,幾乎每一所計算機院校都教過相關的知識。而在 iOS 客戶端開發中,MVC 作為官方推薦的主流架構,不但 SDK 已經為我們實現好了 UIView、UIViewController 等相關的元件,更是有大量的文件和範例供我們參考學習,可以說是一種非常通用而成熟的架構設計。

但 MVC 也有他的壞處。由於 MVC 的概念過於簡單樸素,已經越來越難以適應如今客戶端的需求,大量的程式碼邏輯在 MVC 中並沒有定義得很清楚究竟應該放在什麼地方,導致他們很容易就會堆積在 Controller 裡,成為了人們所說的 Massive View Controller。

MVVM

MVVM,Model-View-ViewModel,一個從 MVC 模式中進化而來的設計模式,最早於2005年被微軟的 WPF 和 Silverlight 的架構師 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 仍然有可能變得和 MVC 中 ViewController 一樣臃腫。

在兩種架構中權衡而產生的架構

兩種架構的優點都想要,缺點又都想避開,我們在兩種架構中權衡了他們的優缺點,設計出了一個新的架構,起了一個名字叫:MVVM without Binding with DataController,架構圖如下:

blob.png

ViewModel

先來看右邊檢視相關的部分,傳統的 MVC 當中 ViewController 中有大量的資料展示和樣式定製的邏輯,我們引入 MVVM 中 ViewModel 的概念,將這部分檢視邏輯移到了 ViewModel 當中。在這個設計中,每一個 View 都會有一個對應的 ViewModel,其包含了這個 View 資料展示和樣式定製所需要的所有資料。同時,我們不引入雙向繫結機制或者觀察機制,而是通過傳統的代理回撥或是通知來將 UI 事件傳遞給外界。而 ViewController 只需要生成一個 ViewModel 並把這個裝配給對應的 View,並接受相應的 UI 事件即可。

這樣做有幾個好處:首先是 View 的完全解耦合,對於 View 來說,只需要確定好相應的 ViewModel 和 UI 事件的回撥介面即可與 Model 層完全隔離;而 ViewController 可以避免與 View 的具體表現打交道,這部分職責被轉交給了 ViewModel,有效的減輕了 ViewController 的負擔;同時我們棄用了傳統繫結機制,使用了傳統的易於理解的回撥機制來傳遞 UI 事件,降低了學習成本,同時使得資料的流入和流出變得易於觀察和控制,降低了維護了調適的成本。

DataController

接下來我們關注 Model 和 VC 之間的關係。如之前提到,在傳統的 MVVM 中,ViewModel 接管了 ViewController 的大部分職責,包括資料獲取,處理,加工等等,導致其很有可能變得臃腫。我們將這部分邏輯抽離出來,引入一個新的部件,DataController。

ViewController 可以向 DataController 請求獲取或是運算元據,也可以將一些事件傳遞給 DataController,這些事件可以是 UI 事件觸發的。DataController 在收到這些請求後,再向 Model 層獲取或是更新資料,最後再將得到的資料加工成 ViewController 最終需要的資料返回。

這樣做之後,使得資料相關的邏輯解耦合,資料的獲取、修改、加工都放在 Data Controller 中處理,View Controller 不關心資料如何獲得,如何處理,Data Controller 也不關心介面如何展示,如何互動。同時 Data Controller 因為完全和介面無關,所以可以有更好的測試性和複用性。

DataController 層和 Model 層之間的界限並不是僵硬的,但需要保證每一個 ViewController 都有一個對應的 DataController。Data Controller 更強調的是其作為業務邏輯對外的介面。而在 DataController 中呼叫更底層的 Model 層邏輯是我們推薦的程式設計正規化,例如資料加工層,網路層,持久層等。

在後面的例子中,我們會更詳細的講解 DataController 的實現細節。

Show me the code

我們以猿題庫主頁為例,展示我們是如何使用應用這個架構的。

blob.png

主頁有幾個部分組成,最上面的小猴子 Banner 頁,用於滾動展示一些活動資訊;中間有一個使用者名稱字的頁面,用於展示使用者資訊和答題情況以及一些心靈雞湯;最底下的這部分是一個課目選擇頁面,展示了使用者開啟的科目入口,在更多選項裡面可以進一步配置這些科目入口。接下來我們會以科目頁面(SubjectView)為例展示一些細節。

ViewController

我們會給每一個 ViewController 都建立一個對應的 DataController。 例如我們給主頁建一個類起名叫APEHomePraticeViewController,同時他會有一個對應的 DataController 起名叫APEHomePraticeDataController。同時我們把頁面拆分為幾個部分,每個部分有一個相對應的 SubView。程式碼如下:

1
2
3
4
5
6
7
8
9
10
@interface APEHomePracticeViewController ()   
@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 *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 *)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 裡呼叫舊有程式碼的邏輯即可,如圖下面這樣的行為都是允許的:

blob.png

ViewModel

每一個 View 都會有一個對應的 ViewModel,這個 ViewModel 會包含展示這個 View 所需要的所有資料。

我們會使用工廠方法來建立 View Model,例如這個例子裡,Subject View Model 不需要關心傳遞給他是什麼樣的 Subject,所有的課目或者只是使用者開啟的科目。

1
2
3
4
5
6
7
8
9
@interface APEHomePracticeSubjectsViewModel : NSObject
 
@property (nonatomic, strong, nonnull) NSArray*cellViewModels;
 
@property (nonatomic, strong, nonnull) UIColor *backgroundColor;
 
+ (nonnull APEHomePracticeSubjectsViewModel *)viewModelWithSubjects:(nonnull NSArray *)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
@protocol APEHomePracticeSubjectsViewDelegate - (void)homePracticeSubjectsView:(nonnull APEHomePracticeSubjectsView *)subjectView
             didPressItemAtIndex:(NSInteger)index;
 
@end
 
@interface APEHomePracticeSubjectsView : UIView
 
@property (nonatomic, strong, nullable, readonly) APEHomePracticeSubjectsViewModel *viewModel;
@property (nonatomic, weak, nullable) id 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 架構設計中有所借鑑。

Lancy

2015.12.30

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

相關文章