打造輕量級 ViewController 之抽離 DataSource/Delegate
前言
UITableView/UICollectionView 是我們開發中使用最為頻繁的兩個控件。關于其使用的實踐網上已經有很多優秀的總結了,所以我不打算再啰嗦了。今天要討論的問題基于 objc.io 的一遍文章 Lighter View Controllers ,此文講述如何通過抽取頻繁出現的配置類型的代碼到專門的一個 DataSource/Delegate 里面來為 Controller 瘦身。我從中受到了啟發,由于文中給出的 demo 不具有通用性,所以打算寫一個比較全面的封裝來組織 DataSource/Delegate 的代碼。
我們先看一下平時都是怎么使用 UITableView 的,一般我們需要做這樣幾件事:
- 注冊需要使用的 cell 的樣式到 UITableView
- 實現 UITableViewDataSource 的幾個方法來告訴 UITableView 什么地方怎么如何顯示 cell
- 實現 UITableViewDelegate 來告訴 UITableView 具體每個 cell 的高度,以及處理點擊事件等
一般情況大致做的就這些。通常的做法就是直接設置當前 controller 為 tableView 的數據源和事件回調委托。這樣會造成很多 controller 有大致一致的代碼。經驗告訴我們,大面積出現類似的代碼就可以考慮把這些代碼抽取出來封裝成一個更加通用的組織形式了。也就是我們要做的事情,其實就是對 UITableViewDataSource 和 UITableViewDelegate 的進一步拆分和封裝。
思考一下我們看到的 tableView 都包含哪些部分,展示了哪些元素。從 Apple 提供的 API 中我們可以看出大致包含 tableViewHeader/tableViewFooter , SectionHeaderView/SectionFooterView , SectionHeaderTitle/SectionFooterTitle , sectionIndex 以及最重要的 Cell 。如果我們把這些東西都映射成為一個數據類型,然后直接讓 tableView 去取對應的部分數據然后渲染到界面上不就好了么,每個頁面我們就不再關心如何去實現 UITableViewDataSource/UITableViewDelegate ,只需要告知 必要的信息 ,其余重復性極高的事情就交給封裝的代碼來做了,就像在配置界面一樣,真正實現「你們做 iOS 的不就是把服務端的數據顯示在界面上就好了么」。
廢話了這么多,直接上我們的解決方案吧!源碼已經放到 GitHub 上了。下面主要說一下怎么用。
代碼組織
代碼主要分為以下幾部分:
- TCDataSourceProtocol : 對 UITableView 和 UICollectionView 按照界面劃分為幾個配置不同界面的模塊,實現者根據需求實現各自的協議,來 “配置” 界面。
- TCDataSource : DataSource 的基類,所有 UITableView 和 UICollectionView 的數據源的基類,其中已經默認實現了重復率高的代碼,其實就是對 UITableViewDataSource/UICollectionViewDataSource 的實現。還實現了 UITableview 的 Move/Edit 操作的輔助方法。 UICollectionView 的 Move 操作輔助方法等。
- TCDelegate : Delegate 的基類,所有 UITableView 和 UICollectionView 的委托的基類,其中實現了與 UIScrollView 相關的一部分功能,比如 Cell 的圖片懶加載。為子類實現一些輔助方法,比如基于 Autolayout 自動計算 Cell/SectionHeaderView/SectionFooterView 行高的輔助方法。
- TCSectionDataMetric : UITableView/UICollectionView 各個分組的組件的數據封裝。包含 SectionHeaderView/SectionFooterView , SectionHeaderTitle/SectionFooterTitle 以及 Cell 等的數據。
- TCGlobalDataMetric :對整個 UITableView/UICollectionView 各個組件的數據的封裝。其作為一個容器,里面包含若干個 TCSectionDataMetric 。
基本使用
下面直接以我工作的通用樣板來說明如何使用,一個場景的文件目錄大致像這樣:
- ProductViewController (基于 UITableView )
- ProductViewModel (采用 RAC 來處理網絡層邏輯)
- ProductDataSource
- ProductDelegate
- Views
- Model
基于這樣的架構, Controller 文件代碼一般保持在 200 到 300 行之間,其他文件行數則更少。這樣一來代碼清晰了,邏輯自然也比較容易厘清,修改功能也容易多了。至于維護那種打開文件一看就是上千行代碼的情況,我的內心是崩潰的。
言歸正傳,來看一下相關類中的關鍵代碼是怎樣的?
ProductViewController 中,初始化 DataSource 和 Delegate 并關聯到 tableView
lazy var dataSource: ProductDataSource = {
ProductDataSource(tableView: self.tableView)
}()
lazy var delegate: ProductDelegate = {
ProductDelegate(tableView: self.tableView)
}()
lazy var tableView: UITableView = {
let tableView = UITableView(frame: CGRectZero, style: .Plain)
...
return tableView
}()
lazy var viewModel: ProductViewModel = {
ProductViewModel()
}()
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = delegate
tableView.dataSource = dataSource
}
internal func methodTakeParamters<T, U>(paramterOne: T, paramterTwo: U) {
navigationController.showViewController(vc, sender: self)
}
</code></pre>
ProductDataSource 需要繼承自 TCDataSource
final class ShopSettingDataSource: TCDataSource {
}
/// 配置能夠顯示基本的 cell 需要的信息
extension ShopSettingDataSource: TCDataSourceable {
/// 注冊 Cell 樣式到 tableView
func registerReusableCell() {
tableView?.registerClass(Cell1.self, forCellReuseIdentifier: Cell1.reuseIdentifier)
tableView?.registerClass(Cell2.self, forCellReuseIdentifier: Cell2.reuseIdentifier)
...
}
/// 返回每個位置對應的 Cell 的重用標識符
func reusableCellIdentifierForIndexPath(indexPath: NSIndexPath) -> String {
/// 可以通過 globalDataMetric.dataForItemAtIndexPath(indexPath)
/// 拿到具體每個 cell 對應的數據,然后通過數據類型來決定使用哪種類型的 cell
return reuseIdentifier
}
/// 為 Cell 配置數據
func loadData(data: TCDataType, forReusableCell cell: TCCellType) {
let reusableCell = cell as! UITableViewCell
reusableCell.setupData(data)
}
}
</code></pre>
ProductDelegate
final class ProductDelegate: TCDelegate {
}
/// 實現委托的方法
extension ProductDelegate {
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
/// 提供點擊事件的處理
/// 通常情況需要跳轉頁面,獲取到與其關聯的 Controller 有多中方式
/// - 直接聲明一個變量引用 Controller
/// - 采用事件響應鏈直接發送消息,不支持傳遞參數
/// - 采用響應鏈來獲取 Controller 并直接調用具體的方法。如下所示
guard let controller = tableView.responderViewController as? ProductViewController else { return }
controller.methodTakeParamters(?, paramterTwo: ?)
/// responderViewController 變量是獲取當前 view 所屬的 controller,請讀者自行思考其實現
}
}
</code></pre>
最后界面都配置好了,你需要為配置好的界面提供數據。也就是 ProductViewModel 中做的事情,從服務器獲取數據,并組裝成框架需要的數據結構,也就是 TCGlobalDataMetric 大致表示如下:
func fetchData() -> TCGlobalDataMetric {
var globalDataMetric = TCGlobalDataMetric.empty()
let data00: ShopSetting = objectFromJSON(json)!
let data01: ShopSetting = objectFromJSON(json)!
globalDataMetric.append(TCSectionDataMetric(itemsData: [data00, data01]))
let data10: ShopSetting = objectFromJSON(json)!
let data11: ShopSetting = objectFromJSON(json)!
globalDataMetric.append(TCSectionDataMetric(itemsData: [data10, data11]))
return globalDataMetric
}
</code></pre>
最后更新數據源中的數據并重載 TableView 即可展示所有的界面了。
dataSource.globalDataMetric = viewModel.fetchData()
tableView.reloadData()
關于 Cell 的高度,你可以自己實現 delegate 的高度相關的方法,或者簡單的返回輔助方法。如下所示
extension ProductDelegate {
public func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return heightForRowAtIndexPath(indexPath)
}
}
需要注意的是,采用這種方式,你需要在 cell 的 layoutSubviews 里面指定多行文本的 preferredMaxLayoutWidth ,或許是我哪里處理錯了,但這樣才能正確計算行高。
override func layoutSubviews() {
super.layoutSubviews()
contentView.setNeedsLayout()
contentView.layoutIfNeeded()
nameLabel.preferredMaxLayoutWidth = CGRectGetWidth(nameLabel.bounds)
}
如果你需要實現的只是簡單的界面展示,那么以上就已經完全滿足需求了。
但是如果只提供這些功能,恐怕封裝的優勢就不是那么明顯了,請接著看。
如何實現其他功能
如何提供 Section Title
- 設置 tableView 的 style 為 .Grouped
-
每個 section 的 TCSectionDataMetric 初始化的時候提供 title
let sectionDataMetric = TCSectionDataMetric(itemsData: [data00, data01], titleForHeader: "header", titleForFooter: "footer")
如何提供 Section header/footer view
擴展 ProductDataSource 讓其遵守 TCTableViewHeaderFooterViewibility 協議
extension ProductDataSource: TCTableViewHeaderFooterViewibility {
/// 注冊 Header/Footer view
func registerReusableHeaderFooterView() {
tableView.tc_registerReusableHeaderFooterViewClass(TableViewHeaderView.self)
tableView.tc_registerReusableHeaderFooterViewClass(TableViewFooterView.self)
}
/// 返回 某個 Section Header 重用標識符
func reusableHeaderViewIdentifierInSection(section: Int) -> String? {
return TableViewHeaderView.reuseIdentifier
}
/// 配置 Header 數據
func loadData(data: TCDataType, forReusableHeaderView headerView: UITableViewHeaderFooterView) {
if let headerView = headerView as? TableViewHeaderView {
headerView.text = data as! String
}
}
/// 返回 某個 Section Footer 重用標識符
func reusableFooterViewIdentifierInSection(section: Int) -> String? {
return TableViewFooterView.reuseIdentifier
}
/// 配置 Footer 數據
func loadData(data: TCDataType, forReusableFooterView footerView: UITableViewHeaderFooterView) {
if let footerView = footerView as? TableViewFooterView {
footerView.text = data as! String
}
}
}
</code></pre>
在 delegate 里面提供 header/footer view ,為了防止與 section title 沖突,所以默認未實現,你需要動手調用輔助方法,如下所示。 如果你使用 Autolayout ,你還可使用輔助方法來計算 header/footer view 的高度。
extension ProductDelegate {
public func tableView(tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return heightForHeaderInSection(section)
}
public func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
return viewForHeaderInSection(section)
}
public func tableView(tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return heightForFooterInSection(section)
}
public func tableView(tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
return viewForFooterInSection(section)
}
}
</code></pre>
如何提供編輯選項
如果你需要插入、刪除 Cell ,只需要實現 TCTableViewEditable 協議即可
extension ProductDatasource: TCTableViewEditable {
func canEditElementAtIndexPath(indexPath: NSIndexPath) -> Bool {
return true
}
func commitEditingStyle(style: UITableViewCellEditingStyle, forData data: TCDataType) {
/// 編輯成功后的操作。比如,請求網絡同步操作結果
}
}
</code></pre>
同時你需要實現 UITabelViewDelegate 的方法來指定編輯模式,不實現默認為刪除操作。
如何提供移動操作
由上面的規律,你應該知道。只需要實現某個協議就可以了。
extension ProductDatasource: TCTableViewCollectionViewMovable {
func canMoveElementAtIndexPath(indexPath: NSIndexPath) -> Bool {
return true
}
func moveElementAtIndexPath(sourceIndexPath: NSIndexPath, toIndexPath destinationIndexPath: NSIndexPath) {
/// 重新排序成功后的操作。比如,請求網絡同步操作結果
}
}
</code></pre>
如何提供索引功能
索引功能由數據來配置,在初始化 TCSectionDataMetric 的時候,帶上 index title 即可,與 section header/footer title 類似。
懶加載圖片
如果你需要該功能,在配置 cell 數據的時候不要設置圖片,在這個方法里面來設置圖片的數據,即可實現圖片的懶加載功能。
extension ProductDatasource: TCImageLazyLoadable {
func lazyLoadImagesData(data: TCDataType, forReusableCell cell: TCCellType) {
debugPrint("\(#file):\(#line):\(self.dynamicType):\(#function)")
}
}
以上提到的都是基于 UITableView 的例子, UICollectionView 原理類似。 你可以實現 TCCollectionSupplementaryViewibility ,為 UICollectionView 提供類似 header/footer view 的效果 當然,懶加載圖片也是可以使用的。
回頭看看
為什么要自己造 TCGlobalDataMetric 和 TCSectionDataMetric
因為像 Lighter View Controllers demo 中的方式, 直接使用數組來表示只能表示單個分組,或者使用二維數組來表示多個分組。這樣會讓人很疑惑。也無法將 header/footer title/view 的數據組合到 與 cell 平級的數據中,組裝數據也分散在不同的地方。所以我們的方式是將整個 tableview 所需要的所有的數據都放到一起,就成了你看到的 TCGlobalDataMetric 和 TCSectionDataMetric 。這樣就可以實現由數據來驅動界面的效果。你需要做的就是按照 UI 效果來組裝整個 tableView/collectionView 的數據即可。
為什么需要基類 ProductDataSource 而不是直接基于協議
試想一下,有個提供數據的協議 DataSourceProtocol ,然后我們默認實現 tableView 的 dataSource 相關代碼。如下所示
protocol DataSourceProtocol {}
/// 實現代碼略
extension DataSourceProtocol {
public func numberOfSectionsInTableView(tableView: UITableView) -> Int {}
public func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {}
public func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {}
...
}
然后我們在使用的時候只需要讓我們自己的數據源實現該協議,由于已經提供了默認實現,所以我們其實什么實現代碼都不用寫了。
extension MyDataSource: DataSourceProtocol {}
這樣不是更加靈活,更加面向協議,更加 swiftly。 嗯。設想是美好的,但是現實總是會嘲笑我們圖樣圖森破。至于為什么不行,請查看參考資料中的鏈接一探究竟
為什么所有的數據都是 TCDataType (aka AnyObject ) 類型的
嗯。這也是框架中做的不好的地方。這么做的原因是每個 cell 所需要的數據類型可能不一樣。 如果都一樣的話,那么很明顯我們可以采用泛型方式在為 cell 配置數據的時候解析出具體的數據類型,一旦這樣做了,就不具有通用性了。 那為什么采用 AnyObject 呢,而不是 Any , Any 表示的范圍更加大。 由于 tableView(_:, sectionForSectionIndexTitle:, atIndex:) -> Int 方法中會用到 indexOf , 該方法接受一個實現了 Equatable 協議的參數。或者自己提供一個 closure 來告訴它如何判斷你提供的元素是否相等。 為了不讓用戶自己提供該方法的實現,就選擇了系統默認實現該協議的類型 AnyObject 。 所以在使用數據(設置 cell 數據)的時候,你需要轉換成對應的具體類型。
guard let data = data as? MyModel else { return }
/// use data...
我還沒有用上 Swift
噢。那你可以看看類似的封裝的 Objective-c 版本 。(其實我也是一直用的 OC 版本的,不久前才翻譯成了 swift ...)
聲明
最后,需要特別聲明。作者水平有限,代碼只代表個人的思考,不保證絕對正確。希望能夠拋磚引玉,有更好的見解還望不吝賜教。 如果能夠對讀者有幫助,那就再好不過了。
感謝Way 和Joey 兩位小伙伴的鼓勵和幫助。
參考資料
- Lighter View Controllers
- AdvancedCollectionView
- When to use dequeueReusableCellWithIdentifier vs dequeueReusableCellWithIdentifier: forIndexPath
- UIScrollView 實踐經驗
- Perfect smooth scrolling in UITableviews
- Using Generics to improve TableView cells
- iOS 9 Tutorial Series: Protocol-Oriented Programming with UIKit