【XE2V 项目收获系列】一、YLExtensions:让 UITableView 及 UICollectionView 更易用

2,604 阅读6分钟

XE2V 项目收获系列:

一、YLExtensions:让 UITableView 及 UICollectionView 更易用

二、YLStateMachine:一个简单的状态机

三、YLRefreshKit:有了它,你可以删除你的刷新代码了

四、Decomposer:一个面向协议的构架模式

五、面向协议编程与操作自动化──以实现刷新自动化为例

前言

XE2V 是一个 V2EX 客户端,作为我的第一个项目,我真切的希望能把它写好。这愿望看起来如此普通,但开始之后才发现,写出让自己满意的代码远没有看起来那么简单,以至于直到现在项目还处于未完成的状态。

由于经验的匮乏及自身的愚钝,许多对一般开发者手到擒来的事情对我来说都成了大问题。不了解的东西太多了,而我应对困难的方法,呃,能避则避。于是,拖延成了常态。但项目总是要完成的,我又不得不在某个时间继续。重新拾起的项目总是左看右看不顺眼,着实面目可憎,心一横,就把项目推倒重来了。于是,时间成了拖延与重写的无尽循环。幸运的是,在项目的一次次重写中,一些问题终究是被解决了,我把它们提取出来做成库,与大家分享。水平所限,定然有诸多不足,欢迎大家批评指正。

问题的提出

当一个 UITableView 或 UICollectionView 页面包含多个种类的 cell 时,注册及配置这些 cell 需要写很多重复的代码,譬如,一个 table view 页面包含了四类 cell:ACell、BCell、CCell 和 DCell,在 tableView(_:cellForRowAt:) 方法中,我们可能这样写:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch indexPath.section {
    case 0:
        let cell = tableView.dequeueReusableCell(withIdentifier: "ACell", for: indexPath) as! ACell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    case 1:
        let cell = tableView.dequeueReusableCell(withIdentifier: "BCell", for: indexPath) as! BCell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    case 2:
        let cell = tableView.dequeueReusableCell(withIdentifier: "CCell", for: indexPath) as! CCell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    case 3:
        let cell = tableView.dequeueReusableCell(withIdentifier: "DCell", for: indexPath) as! DCell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    }
}

一种模式重复四遍,实在不够优雅。理想中,tableView(_:cellForRowAt:) 方法应该类似这样:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(...)
    cell.configure(data[indexPath.section][indexPath.row])
    return cell
}

那么,能否找到一种方法实现上面的效果呢?

简化 tableView(_:cellForRowAt:) 方法

首先,我们要使得 dequeueReusableCell(…) 方法能够在不同的 Identifier 下返回不同的 cell。如何做到?不透明类型正是用来解决这类问题的。为此,我们给 UITableView 添加一个扩展:

extension UITableView {
    func dequeueReusableCell(
        for indexPath: IndexPath,
        with identifiers: [String]
    ) -> some UITableViewCell {
        for (index, identifier) in identifiers.enumerated() where index == indexPath.section {
            let cell = dequeueReusableCell(withIdentifier: identifier, for: indexPath)
            return cell
        }
        
        fatalError()
    }
}

接下来,每类 cell 都要有一个 configure(_:) 方法,这容易完成,扩展一下 UITableViewCell 即可:

@objc protocol Configurable {
    func configure(_ model: Any?)
}

extension UITableViewCell: Configurable {
    func configure(_ model: Any?) {  }
}

于是,tableView(_:cellForRowAt:) 方法中我们就可以这样写:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(for: indexPath, with: ["ACell", "BCell", "CCell", "DCell"])
    cell.configure(model[indexPath.section][indexPath.row])
    return cell
}

表示 Identifier 的更好方式

字符串容易出现拼写错误,有没有更好地方式表示 Identifier 呢?一种解决方式是给 cell 添加一个 identifier 属性,这样我们就可以利用 Xcode 的自动补全功能帮助我们避免错误。我们可以这样做:

protocol ReusableView { }

extension ReusableView {
    static var reuseIdentifier: String {
        return String(describing: self)
    }
}

extension UITableViewCell: ReusableView { }

然后,在 tableView(_:cellForRowAt:) 方法中使用:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(for: indexPath, with: [ACell.reuseIdentifier, BCell.reuseIdentifier, CCell.reuseIdentifier, DCell.reuseIdentifier])
    cell.configure(model[indexPath.section][indexPath.row])
    return cell
}

简化 cell 的注册

另一个出现重复代码的地方是 cell 注册时,比如,当 A、B、C、D 四类 cell 由纯代码方式创建时,我们会这样注册:

tableView.register(ACell.self, forCellReuseIdentifier: ACell.reuseIdentifier)
tableView.register(BCell.self, forCellReuseIdentifier: BCell.reuseIdentifier)
tableView.register(CCell.self, forCellReuseIdentifier: CCell.reuseIdentifier)
tableView.register(DCell.self, forCellReuseIdentifier: DCell.reuseIdentifier)

能否简化注册过程?

仔细观察注册方法,其实只需给它提供一个 UITableViewCell.Type 类型的参数即可。基于此,我们可以给 UITableView 添加一个这样的扩展:

extension UITableView {
    func registerCells(with cells: [UITableViewCell.Type]) {
        for cell in cells {
            register(cell, forCellReuseIdentifier: cell.reuseIdentifier)
        }
    }
}

从而,在注册时只需写一行代码:

tableView.registerCells(with: [ACell.self, BCell.self, CCell.self, DCell.self])

如果 cell 是用 nib 方式创建的呢?这也简单。我们先扩展一下 UITableViewCell:

protocol NibView { }

extension NibView where Self: UIView {
    static var nib: UINib {
        return UINib(nibName: String(describing: self), bundle: nil)
    }
}

extension UITableViewCell: NibView { }

再给 UITableView 添加一个扩展:

extension UITableView {
    func registerNibs(with cells: [UITableViewCell.Type]) {
        for cell in cells {
            register(cell.nib, forCellReuseIdentifier: cell.reuseIdentifier)
        }
    }
}

然后我们就可以用类似的方式注册 nib 方式创建的 cell 了。

如此,问题就都得到了解决。不过,审视一下 dequeueReusableCell(for:with:) 方法和 registerCells(with:) 方法,它们的参数感觉,呃,不大漂亮。有没有更好地表示方式?嗯,我们可以把它们放入 table view 的 Model 的属性中,使用时调用一下就行了。

给 Model 下一个定义

啊,Model!说了这么久,我们还没有考虑过它。

什么是 Model?你可能会说它是一个提供数据的东西。确实,我们一般都是把 Model 当作数据提供者使用。Model 会有一个 data 的只读属性提供数据,再考虑到 cell 的分组及数据类型的不同,data 的类型应为 [[Any]]。

Model 的功能只是如此吗?事实上,对于 UITableView 及 UICollectionView 的 Model,它可以承载更多。每个 table view 都有一个 Model 和若干种类的 cell,于是,Model 和 cell 间可以建立起联系,我们可以把 cell 的类型存入 Model 中,在需要时取用。需要注意的是,注册 cell 通常在 Model 实例化之前,所以 cell 类型应该存入 Model 的类方法之中。此外,table view 可能会分页,所以 Model 最好能有一个 nextPage 的属性。

有了上面的讨论,我们给 model 下一个定义:

protocol Pageable {
    var nextPage: Int? { get }
}

extension Pageable {
    var nextPage: Int? { nil }
}

protocol ModelType: Pageable {
    static var tCells: [UITableViewCell.Type]? { get }
    static var tNibs: [UITableViewCell.Type]? { get }
    // All cell types, sort by display order
    static var tAll: [UITableViewCell.Type]? { get }
    
    // Store model data in display order
    var data: [[Any]] { get }
}

extension ModelType {
    static var tCells: [UITableViewCell.Type]? { nil }
    static var tNibs: [UITableViewCell.Type]? { nil }
    static var tAll: [UITableViewCell.Type]? { nil }
}

使用

于是,我们以后使用 UITableView 可以这么做:

首先,让 Model 遵循 ModelType:

struct SomeModel: ModelType {
    let someA: [A]
    let someB: [B]
    let someC: [C]
    let someD: [D]
    
    var data: [[Any]] {
    	return [someA, someB, someC, someD]
    }
}

extension SomeModel {
    static var tCells: [UITableViewCell.Type]? {
        [ACell.self, BCell.self]
    }
    
    static var tNibs: [UITableViewCell.Type]? {
        [CCell.self, DCell.self]
    }
    
    static var tAll: [UITableViewCell.Type]? {
        // Sort by display order
        [ACell.self, BCell.self, CCell.self, DCell.self]
    }
}

接着,在 cell 中实现 configure(_:) 方法:

class SomeCell: UITableViewCell {
    ...
    // Configure cell
    override func configure(_ model: Any?) {
        ...
    }
}

最后,在 ViewController 中:

1. 创建 model 对象
let someModel = SomeModel(..)

2. 注册 cell
override func viewDidLoad() {
    super.viewDidLoad()
    ...
    tableView.registerCells(with: SomeModel.tCells!)
    tableView.registerNibs(with: SomeModel.tNibs!)
}

3. 创建并配置 cell
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(for: indexPath, with: SomeModel.tAll!)
    cell.configure(someModel.data[indexPath.section][indexPath.row])
    return cell
}

这里对 dequeueReusableCell(for:with:) 方法做了一些修改:

func dequeueReusableCell(
    for indexPath: IndexPath,
    with cells: [UITableViewCell.Type]
) -> some UITableViewCell {
    for (index, cell) in cells.enumerated() where index == indexPath.section {
        let cell = dequeueReusableCell(withIdentifier: cell.reuseIdentifier, for: indexPath)
        return cell
    }

    fatalError()
}

UICollectionView 的解决方案与之类似,就不做介绍了。

下篇预告

为了实现自动刷新功能,需要用到状态机,在 GitHub 上粗略地查看了几个 Swift 版本的状态机,都不能让我满意(其实也没怎么看懂😅️),于是就自己写了一个。下篇文章,我将介绍如何从零开始创建一个状态机。

源码地址: YLExtensions