打造轻量级 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 的高度,以及处理点击事件等

一般情况大致做的就这些。通常的做法就是直接设置当前 controllertableView 的数据源和事件回调委托。这样会造成很多 controller 有大致一致的代码。经验告诉我们,大面积出现类似的代码就可以考虑把这些代码抽取出来封装成一个更加通用的组织形式了。也就是我们要做的事情,其实就是对 UITableViewDataSourceUITableViewDelegate 的进一步拆分和封装。

思考一下我们看到的 tableView 都包含哪些部分,展示了哪些元素。从 Apple 提供的 API 中我们可以看出大致包含 tableViewHeader/tableViewFooterSectionHeaderView/SectionFooterViewSectionHeaderTitle/SectionFooterTitlesectionIndex 以及最重要的 Cell。如果我们把这些东西都映射成为一个数据类型,然后直接让 tableView 去取对应的部分数据然后渲染到界面上不就好了么,每个页面我们就不再关心如何去实现 UITableViewDataSource/UITableViewDelegate ,只需要告知必要的信息,其余重复性极高的事情就交给封装的代码来做了,就像在配置界面一样,真正实现「你们做 iOS 的不就是把服务端的数据显示在界面上就好了么」。

废话了这么多,直接上我们的解决方案吧!源码已经放到 GitHub 上了。下面主要说一下怎么用。

代码组织

代码主要分为以下几部分:

  • TCDataSourceProtocol: 对 UITableViewUICollectionView 按照界面划分为几个配置不同界面的模块,实现者根据需求实现各自的协议,来 “配置” 界面。
  • TCDataSourceDataSource 的基类,所有 UITableViewUICollectionView 的数据源的基类,其中已经默认实现了重复率高的代码,其实就是对 UITableViewDataSource/UICollectionViewDataSource 的实现。还实现了 UITableviewMove/Edit 操作的辅助方法。UICollectionViewMove 操作辅助方法等。
  • TCDelegateDelegate 的基类,所有 UITableViewUICollectionView 的委托的基类,其中实现了与 UIScrollView 相关的一部分功能,比如 Cell的图片懒加载。为子类实现一些辅助方法,比如基于 Autolayout 自动计算 Cell/SectionHeaderView/SectionFooterView 行高的辅助方法。
  • TCSectionDataMetricUITableView/UICollectionView 各个分组的组件的数据封装。包含 SectionHeaderView/SectionFooterView, SectionHeaderTitle/SectionFooterTitle 以及 Cell 等的数据。
  • TCGlobalDataMetric:对整个 UITableView/UICollectionView 各个组件的数据的封装。其作为一个容器,里面包含若干个 TCSectionDataMetric

基本使用

下面直接以我工作的通用样板来说明如何使用,一个场景的文件目录大致像这样:

  • ProductViewController(基于 UITableView)
  • ProductViewModel(采用 RAC 来处理网络层逻辑)
  • ProductDataSource
  • ProductDelegate
  • Views
  • Model

基于这样的架构,Controller 文件代码一般保持在 200 到 300 行之间,其他文件行数则更少。这样一来代码清晰了,逻辑自然也比较容易厘清,修改功能也容易多了。至于维护那种打开文件一看就是上千行代码的情况,我的内心是崩溃的。

言归正传,来看一下相关类中的关键代码是怎样的?

ProductViewController 中,初始化 DataSourceDelegate 并关联到 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)
      }
}

需要注意的是,采用这种方式,你需要在 celllayoutSubviews 里面指定多行文本的 preferredMaxLayoutWidth,或许是我哪里处理错了,但这样才能正确计算行高。

override func layoutSubviews() {  
    super.layoutSubviews()
    contentView.setNeedsLayout()
    contentView.layoutIfNeeded()
    nameLabel.preferredMaxLayoutWidth = CGRectGetWidth(nameLabel.bounds)
}

如果你需要实现的只是简单的界面展示,那么以上就已经完全满足需求了。

但是如果只提供这些功能,恐怕封装的优势就不是那么明显了,请接着看。

如何实现其他功能

如何提供 Section Title

  • 设置 tableViewstyle.Grouped
  • 每个 sectionTCSectionDataMetric 初始化的时候提供 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 的方法来指定编辑模式,不实现默认为删除操作。

如何提供移动操作

由上面的规律,你应该知道。只需要实现某个协议就可以了。对于 UICollectionView 需要 iOS9+ 才能该协议才会生效,所以如果你需要重新排序的功能,GitHub 有你需要的实现

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 的效果 当然,懒加载图片也是可以使用的。

回头看看

为什么要自己造 TCGlobalDataMetricTCSectionDataMetric

因为像 Lighter View Controllers demo 中的方式, 直接使用数组只能表示单个分组,或者使用二维数组来表示多个分组。这样会让人很疑惑。也无法将 header/footer title/view 的数据组合到 与 cell 平级的数据中,数据也分散在不同的地方。所以我们的方式是将整个 tableview 所需要的所有的数据都放到一起,就成了你看到的 TCGlobalDataMetricTCSectionDataMetric。这样就可以实现由数据来驱动界面的效果。你需要做的就是按照 UI 效果来组装整个 tableView/collectionView 的数据即可。

为什么需要基类 ProductDataSource 而不是直接基于协议

试想一下,有个提供数据的协议 DataSourceProtocol,然后我们默认实现 tableViewdataSource 相关代码。如下所示:

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 {}
    ...
}

然后我们在使用的时候只需要让我们自己的数据源实现该协议,由于已经提供了默认实现,所以我们其实什么实现代码都不用写了。

extension MyDataSource: DataSourceProtocol {}  

这样不是更加灵活,更加面向协议,更加 swiftly。 嗯。设想是美好的,但是现实总是会嘲笑我们图样图森破。至于为什么不行,请查看参考资料中的链接一探究竟。

为什么所有的数据都是 TCDataType(aka AnyObject) 类型的

嗯。这也是框架中做的不好的地方。这么做的原因是每个 cell 所需要的数据类型可能不一样。 如果都一样的话,那么很明显我们可以采用泛型方式在为 cell 配置数据的时候解析出具体的数据类型,一旦这样做了,就不具有通用性了。 那为什么采用 AnyObject 呢,而不是 AnyAny 表示的范围更加大。 由于 tableView(_:, sectionForSectionIndexTitle:, atIndex:) -> Int 方法中会用到 indexOf, 该方法接受一个实现了 Equatable 协议的参数。或者自己提供一个 closure 来告诉它如何判断你提供的元素是否相等。 为了不让用户自己提供该方法的实现,我们选择了系统默认实现该协议的范围尽可能大的类型 AnyObject。 所以在使用数据(设置 cell 数据)的时候,你需要转换成对应的具体类型。

guard let data = data as? MyModel else { return }  
/// use data...

我还没有用上 Swift

噢。那你可以看看类似封装的 Objective-c 版本。(之前也是用的 OC 版本的,新项目启动就翻译成了 swift...)

声明

最后,需要特别声明。作者水平有限,代码只代表个人的思考,不保证绝对正确。希望能够抛砖引玉,有更好的见解还望不吝赐教。 如果能够对读者有所帮助,那就再好不过了。

感谢 WayJoey 两位小伙伴的鼓励和帮助。

参考资料


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

mochxiao

Read more posts by this author.

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!