前言
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
。
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 |
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) } |
ProductDataSource
需要繼承自 TCDataSource
。
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 |
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) } } |
ProductDelegate
原始碼大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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,請讀者自行思考其實現 } } |
最後介面都配置好了,你需要為配置好的介面提供資料。也就是 ProductViewModel
中做的事情,從伺服器獲取資料,並組裝成框架需要的資料結構,也就是 TCGlobalDataMetric
大致表示如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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 } |
最後更新資料來源中的資料並過載 TableView
即可展示所有的介面了。
1 2 |
dataSource.globalDataMetric = viewModel.fetchData() tableView.reloadData() |
關於 Cell
的高度,你可以自己實現 delegate
的高度相關的方法,或者簡單的返回輔助方法。如下所示
1 2 3 4 5 |
extension ProductDelegate { public func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat { return heightForRowAtIndexPath(indexPath) } } |
需要注意的是,採用這種方式,你需要在 cell
的 layoutSubviews
裡面指定多行文字的 preferredMaxLayoutWidth
,或許是我哪裡處理錯了,但這樣才能正確計算行高。
1 2 3 4 5 6 |
override func layoutSubviews() { super.layoutSubviews() contentView.setNeedsLayout() contentView.layoutIfNeeded() nameLabel.preferredMaxLayoutWidth = CGRectGetWidth(nameLabel.bounds) } |
如果你需要實現的只是簡單的介面展示,那麼以上就已經完全滿足需求了。
但是如果只提供這些功能,恐怕封裝的優勢就不是那麼明顯了,請接著看。
如何實現其他功能
如何提供 Section Title
- 設定
tableView
的style
為.Grouped
- 每個
section
的TCSectionDataMetric
初始化的時候提供title
1let sectionDataMetric = TCSectionDataMetric(itemsData: [data00, data01], titleForHeader: "header", titleForFooter: "footer")
如何提供 Section header/footer view
擴充套件 ProductDataSource
讓其遵守 TCTableViewHeaderFooterViewibility
協議
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 |
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 } } } |
在 delegate
裡面提供 header/footer view
,為了防止與 section title
衝突,所以預設未實現,你需要動手呼叫輔助方法,如下所示。 如果你使用 Autolayout
,你還可使用輔助方法來計算 header/footer view
的高度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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) } } |
如何提供編輯選項
如果你需要插入、刪除 Cell
,只需要實現 TCTableViewEditable
協議即可。
1 2 3 4 5 6 7 8 9 |
extension ProductDatasource: TCTableViewEditable { func canEditElementAtIndexPath(indexPath: NSIndexPath) -> Bool { return true } func commitEditingStyle(style: UITableViewCellEditingStyle, forData data: TCDataType) { /// 編輯成功後的操作。比如,請求網路同步操作結果 } } |
同時你需要實現 UITabelViewDelegate
的方法來指定編輯模式,不實現預設為刪除操作。
如何提供移動操作
由上面的規律,你應該知道。只需要實現某個協議就可以了。對於 UICollectionView
需要 iOS9+ 才能該協議才會生效,所以如果你需要重新排序的功能,GitHub 有你需要的實現。
1 2 3 4 5 6 7 8 9 |
extension ProductDatasource: TCTableViewCollectionViewMovable { func canMoveElementAtIndexPath(indexPath: NSIndexPath) -> Bool { return true } func moveElementAtIndexPath(sourceIndexPath: NSIndexPath, toIndexPath destinationIndexPath: NSIndexPath) { /// 重新排序成功後的操作。比如,請求網路同步操作結果 } } |
如何提供索引功能
索引功能由資料來配置,在初始化 TCSectionDataMetric
的時候,帶上 index title
即可,與 section header/footer title
類似。
懶載入圖片
如果你需要該功能,在配置 cell
資料的時候不要設定圖片,在這個方法裡面來設定圖片的資料,即可實現圖片的懶載入功能。
1 2 3 4 5 |
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
相關程式碼。如下所示:
1 2 3 4 5 6 7 8 |
protocol DataSourceProtocol {} /// 實現程式碼略 extension DataSourceProtocol: UITableViewDataSource { public func numberOfSectionsInTableView(tableView: UITableView) -> Int {} public func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {} public func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {} ... } |
然後我們在使用的時候只需要讓我們自己的資料來源實現該協議,由於已經提供了預設實現,所以我們其實什麼實現程式碼都不用寫了。
1 |
extension MyDataSource: DataSourceProtocol {} |
這樣不是更加靈活,更加面向協議,更加 swiftly。 嗯。設想是美好的,但是現實總是會嘲笑我們圖樣圖森破。至於為什麼不行,請檢視參考資料中的連結一探究竟。
為什麼所有的資料都是 TCDataType
(aka AnyObject
) 型別的
嗯。這也是框架中做的不好的地方。這麼做的原因是每個 cell
所需要的資料型別可能不一樣。 如果都一樣的話,那麼很明顯我們可以採用泛型方式在為 cell
配置資料的時候解析出具體的資料型別,一旦這樣做了,就不具有通用性了。 那為什麼採用 AnyObject
呢,而不是 Any
, Any
表示的範圍更加大。 由於 tableView(_:, sectionForSectionIndexTitle:, atIndex:) -> Int
方法中會用到 indexOf
, 該方法接受一個實現了 Equatable
協議的引數。或者自己提供一個 closure
來告訴它如何判斷你提供的元素是否相等。 為了不讓使用者自己提供該方法的實現,我們選擇了系統預設實現該協議的範圍儘可能大的型別 AnyObject
。 所以在使用資料(設定 cell
資料)的時候,你需要轉換成對應的具體型別。
1 2 |
guard let data = data as? MyModel else { return } /// use data... |
我還沒有用上 Swift
噢。那你可以看看類似封裝的 Objective-c 版本。(之前也是用的 OC
版本的,新專案啟動就翻譯成了 swift
…)
宣告
最後,需要特別宣告。作者水平有限,程式碼只代表個人的思考,不保證絕對正確。希望能夠拋磚引玉,有更好的見解還望不吝賜教。 如果能夠對讀者有所幫助,那就再好不過了。
參考資料
- 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