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) }
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) } }
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,请读者自行思考其实现 } }
最后界面都配置好了,你需要为配置好的界面提供数据。也就是 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 }
最后更新数据源中的数据并重载 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 } } }
在 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) } }
如果你需要插入、删除 Cell
,只需要实现 TCTableViewEditable
协议即可
extension ProductDatasource: TCTableViewEditable { func canEditElementAtIndexPath(indexPath: NSIndexPath) -> Bool { return true } func commitEditingStyle(style: UITableViewCellEditingStyle, forData data: TCDataType) { /// 编辑成功后的操作。比如,请求网络同步操作结果 } }
同时你需要实现 UITabelViewDelegate
的方法来指定编辑模式,不实现默认为删除操作。
由上面的规律,你应该知道。只需要实现某个协议就可以了。
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
数据的时候不要设置图片,在这个方法里面来设置图片的数据,即可实现图片的懒加载功能。
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 两位小伙伴的鼓励和帮助。