iOS架構師之路:控制器(View Controller)瘦身設計
前言
古老的MVC架構是容易被iOS開發者理解和接受的設計模式,但是由于iOS開發的項目功能越來越負責龐大,項目代碼也隨之不斷壯大,MVC的模糊定義導致我們的業務開發工程師很容易把大量的代碼寫到視圖控制器中,行業中對這種控制器有個專業詞匯Massive ViewControler(臃腫的視圖控制器)。代碼臃腫導致可讀性可維護性差,而且這種不清晰的設計還有許多的副作用,比如代碼重用性差。作為架構師需要關注項目的代碼質量。指導業務開發工程師寫出高質量,高健壯性,高可用的代碼也是很重要的工作。因此需要知道一些為控制器瘦身的技巧,并在項目中幫助業務開發工程師合理的運用它們。本文翻譯一篇國外優秀文章: Lighter View Controllers (https://www.objc.io/issues/1-view-controllers/lighter-view-controllers/)。
分離數據源 (Data Source) 等 協議 (Protocol)
瘦身控制器的有效方法之一就是將實現 UITableViewDataSource 協議相關的代碼封裝成一個類(比如本文中的 ArraryDataSource )。如果你多用幾次這個設計,你就會創建復用性高的封裝類。
舉個例子,示例工程中的類 Photos控制器實現如下數據源方法:
# pragma mark Pragma
(Photo)photoAtIndexPath:(NSIndexPath)indexPath {
return photos[(NSUInteger)indexPath.row];
}
(NSInteger)tableView:(UITableView*)tableView
numberOfRowsInSection:(NSInteger)section {
return photos.count;
}
(UITableViewCell)tableView:(UITableView)tableView
cellForRowAtIndexPath:(NSIndexPath*)indexPath {
PhotoCell* cell = [tableView dequeueReusableCellWithIdentifier:PhotoCellIdentifier
forIndexPath:indexPath];
Photo* photo = [self photoAtIndexPath:indexPath];
cell.label.text = photo.name;
return cell;
}
</code></pre>
上面示例的數據源的實現都與 NSArray 有關,還有一個方法的實現與 Photo 有關(Photo 與 Cell 呈一一對應關系)。下面讓我們來把與 NSArray 相關的代碼從 控制器中抽離出來,并改用 block 來設置 cell 的視圖。當然你也可以用代理來實現,取決于你的個人喜好。
@implementation ArrayDataSource
(id)itemAtIndexPath:(NSIndexPath*)indexPath {
return items[(NSUInteger)indexPath.row];
}
(NSInteger)tableView:(UITableView*)tableView
numberOfRowsInSection:(NSInteger)section {
return items.count;
}
(UITableViewCell)tableView:(UITableView)tableView
cellForRowAtIndexPath:(NSIndexPath*)indexPath {
id cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier
forIndexPath:indexPath];
id item = [self itemAtIndexPath:indexPath];
configureCellBlock(cell,item);
return cell;
}
@end
</code></pre>
現在我們可以控制器中的三個數據源代理方法可以干掉,并且把 控制器的 dataSource 設置為 ArrayDataSource 的實例。
void (^configureCell)(PhotoCell, Photo) = ^(PhotoCell cell, Photo photo) {
cell.label.text = photo.name;
};
photosArrayDataSource = [[ArrayDataSource alloc] initWithItems:photos
cellIdentifier:PhotoCellIdentifier
configureCellBlock:configureCell];
self.tableView.dataSource = photosArrayDataSource;
</code></pre>
通過上面的方法,你就可以把設置 Cell 視圖的工作從 控制器中抽離出來。現在你不需要再關心indexPath如何與 NSArrary 中的元素如何關聯,當你需要將數組中的元素在其它 UITableView 中展示時你可以重用以上代碼。你也可以在 ArrayDataSource 中實現更多的方法,比如tableView:commitEditingStyle:forRowAtIndexPath:。
這樣做還能帶來額外的好處,我們還可以針對這部分實現編寫單獨的單元測試。不僅僅針對NSArray,我們可以使用這種分離思路處理其他數據容器(比如NSDictionary)。
該技巧同樣適用于其他 Protocol ,比如 UICollectionViewDataSource 。通過該協議,你可以定義出各種各樣的 UICollectionViewCell 。假如有一天,你需要在代碼在使用到 UICollectionView 來替代當前的 UITableView,你只需要修改幾行 控制器中的代碼即可完成替換。你甚至能夠讓你的 DataSource 類同時實現 UICollectionViewDataSource 協議和 UITableViewDataSource 協議。
把 業務邏輯 移至 Model
下面是一段位于 控制器中的代碼,作用是找出針對用戶active priority的一個列表。
- (void)loadPriorities {
NSDate* now = [NSDate date];
NSString* formatString = @"startDate <= %@ AND endDate >= %@";
NSPredicate* predicate = [NSPredicate predicateWithFormat:formatString, now, now];
NSSet* priorities = [self.user.priorities filteredSetUsingPredicate:predicate];
self.priorities = [priorities allObjects];
}
然而,假如你把代碼實現移至 User 的 Category 中,控制器中的代碼將會更簡潔、更清晰。
將以上代碼移到User+Extension.m中
- (NSArray*)currentPriorities {
NSDate* now = [NSDate date];
NSString* formatString = @"startDate <= %@ AND endDate >= %@";
NSPredicate* predicate = [NSPredicate predicateWithFormat:formatString, now, now];
return [[self.priorities filteredSetUsingPredicate:predicate] allObjects];
}
ViewController.m 中的代碼可以改成這個鬼樣子,是不是明顯要簡潔許多,可讀性強很多呢。
- (void)loadPriorities {
self.priorities = [self.user currentPriorities];
}
實際開發中,有些代碼很難移至 model 對象中,但是很明顯這些代碼與 model 對象有關。針對這種情況,我們可以創建一個 store 類,并把相關代碼遷移進去。
創建 Store 類
在這個示例項目工程中,我們有一段用于從本地文件加載數據并解析的代碼:
- (void)readArchive {
NSBundle* bundle = [NSBundle bundleForClass:[self class]];
NSURL *archiveURL = [bundle URLForResource:@"photodata"
withExtension:@"bin"];
NSAssert(archiveURL != nil, @"Unable to find archive in bundle.");
NSData *data = [NSData dataWithContentsOfURL:archiveURL
options:0
error:NULL];
NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
_users = [unarchiver decodeObjectOfClass:[NSArray class] forKey:@"users"];
_photos = [unarchiver decodeObjectOfClass:[NSArray class] forKey:@"photos"];
[unarchiver finishDecoding];
}
控制器 不應該負責以上的工作,控制器只要負責數據調度就可以了,數據獲取的工作我們完全可以交給 store 對象來負責。通過將這些代碼從 控制器 中抽離出來,我們可以更容易復用、測試這些方法、同時讓 控制器 變得更輕巧( Store 對象一般負責數據的加載、緩存、持久化。 Store 對象也經常被稱作 Service Layer 對象,或者 Repository 對象)。
將 Web Service 邏輯移至 Model 層
這與上一個主題非常相似:別把 Web Service 相關的代碼寫在 控制器 中,應該把這部分代碼抽離出來。并通過方法的回調對數據進行處理。
不僅如此,你還可以把處理異常情況的工作也轉交給 Store 對象負責。
把視圖相關的代碼移至 View 層
同樣構建視圖(尤其是復雜視圖)的代碼也不應該寫在 View Controller (關我毛事啊,我只負責調度和通信啊)中。要么使用 Interface Builder ,要么封裝一個 Vew 的子類來完成這部分工作。假設現在需要實現自定義一個日期選擇器。我們應該新建一個 DatePickerView 的子類來完成構建視圖的工作,而不是把這部分工作放在 View Controller 中完成。同樣的,這將是你的代碼更簡潔,復用性更強。
除了用 code 的形式來實現自定義視圖,你也可以使用 Interface Builder 來完成構建自定義視圖的工作。很多人都認為 Interface Builder 只能用于為 View Controller 構建視圖,其實不然,你可以通過單獨的 nib 文件來加載在 Interface Builder 中構建的自定義視圖。在示例工程當中,我們創建了一個包含了 Photo Cell 視圖的 PhotoCell.xib 文件。

如圖所示,我們在 view 中創建了屬性(無需設置 File’s Owner 對象)并把它們與 Interface Builder 中的視圖關聯起來。這個方法同樣適用于構建其它自定義視圖。
通訊
我們在 控制器 中經常需要與其它 控制器 、 Model 、 View 進行通訊。雖然這本來就是 控制器 應該負責處理的事情,但我們依然可以用盡可能少的代碼完成我們控制器的負責的工作。
現在已經有很多成熟的方案來建立 控制器 與 View 的通訊(例如 KVO 和 fetched results controllers )。然而 控制器 之間的通訊目前還沒有類似的方案可以借鑒。
在實際開發中,我們經常需要把 遇到需要把 控制器 持有的一些狀態信息,傳遞到 多個 控制器的需求 。通常我們會將這些狀態信息保存在一個對象中然后傳遞給其他的視圖控制器。這部分的瘦身技巧比較復雜,我留在以后再專門講解吧。
結論
我們已經展示了一些瘦身 控制器 的方法。作為架構師我們不可能完全照搬這些設計技巧,但我們需要清楚我們這么做的目的,我們只有一個目標:使得代碼更易于維護,只要架構師在review代碼時時刻關注這個目標,我們可以就可以擴展這些技巧,靈活運用到項目中。通過了解這些方法,我們能夠更好的處理好復雜的視圖控制器,并且讓這些視圖控制器的代碼更整潔,更清晰。