【XE2V项目收获系列】五、面向协议编程与操作自动化──以实现刷新自动化为例

1,157 阅读10分钟

XE2V 项目收获系列:

一、YLExtensions:让 UITableView 及 UICollectionView 更易用

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

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

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

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

前言

Swift 语言的一大亮点就是支持面向协议编程(POP),相比于传统的面向对象编程(OOP),POP 有诸多优点,比如,喵神在其文章《面向协议编程与 Cocoa 的邂逅 (上)》中提到,POP 解决了 OOP 的动态派发安全性与横切关注点的问题,并在一定程度上避免了 OOP 中的菱形缺陷。POP 的优势不止于此,它还有着天生的自动化基因。思考一下,协议的作用是什么?协议规范了组件的行为与特征 ;自动化又是什么呢?自动化就是将有着特定行为与特征的组件以恰当的方式组合起来 。所以,我们可以以恰当的方式组合协议化的组件以实现自动化 。接下来,我会以刷新为例,介绍如何通过使用协议最终实现刷新操作的自动化。

一、简化 UITableView 的创建及配置过程

当一个页面有多种 cell 时,注册及配置 cell 需要写很多重复的代码,有没有方法简化这一过程?让我们先来观察一下注册方法:

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

register(_:forCellReuseIdentifier:) 方法需要提供两个参数── cell 的类型和与 cell 对应的重用标识符。既然重用标识符与 cell 类型是一一对应的,直接给 cell 添加一个类型属性当作它的重用标识符好了。基于此,我们定义 ReusableView 协议。

  • ReusableView

public protocol ReusableView { }

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

extension UITableViewCell: ReusableView { }
  • NibView

当 cell 以 nib 方式创建时,注册方式有所不同,为此,我们定义 NibView 协议:

public protocol NibView { }

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

extension UITableViewCell: NibView { }

于是,注册 cell 时可以这么写:

tableView.register(ACell.self, forCellReuseIdentifier: ACell.reuseIdentifier)

注册 cell 时只需提供它的类型即可。

  • 扩展 UITableView

接下来,为了解决注册方法需要重复书写的问题,我们给 UITableView 添加扩展方法:

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

从而,注册多个类型的 cell 变得相当简洁:

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

另一个出现重复代码的地方在 tableView(_:cellForRowAt:) 方法中。当页面有多类 cell 时,我们可能这么写:

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

能否改进这段代码?这里的关键在于,dequeueReusableCell(…) 方法要能够在不同的 identifier 下返回不同的 cell。你想到了什么?对了,Swift 5.1 推出的不透明类型正是解决这个问题的钥匙。我们只需给 UITableView 添加一个扩展:

extension UITableView {
    public 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()
    }
}

然后 tableView(_:cellForRowAt:) 方法中就可以这样写:

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

为了让 cell 有一个 configure(_:) 方法,需要定义一个 Configurable 协议:

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

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

注册及配置过程中手写 cell 类型显得不那么优雅,因为 Model 是与页面对应的,我们可以把页面的 cell 类型当作 Model 的类型属性存储,需要时调用 Model 就可以了。为此我们定义 ModelType

public protocol ModelType {
    static var tCells: [UITableViewCell.Type]? { get }
    static var tNibs: [UITableViewCell.Type]? { get }
    // All cell types to be registered, sort by display order
    static var tAll: [UITableViewCell.Type]? { get }
    
    var pageablePropertyPath: WritableKeyPath<SomeModel, [Something]>? { get }
    
    // 返回所有 cell 的 model
    var data: [[Any]] { get }
}

extension ModelType {
    public static var tCells: [UITableViewCell.Type]? { nil }
    public static var tNibs: [UITableViewCell.Type]? { nil }
    public static var tAll: [UITableViewCell.Type]? { nil }
    
    var pageablePropertyPath: WritableKeyPath<SomeModel, [Something]>? { nil }
}

于是,当一个 model 遵循并实现了 ModelType ,我们可以这样注册及配置 cell:

// 注册 cell
tableView.registerCells(with: SomeModel.tCells!)
tableView.registerNibs(with: SomeModel.tNibs!)

// 创建及配置 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
}

二、将各个页面的刷新操作聚合到一起

通常我们会为每个页面编写非常相似的刷新代码,这个过程中包含大量的重复,所以,一个自然的问题就是,有没有可能将刷新操作都聚合到一起呢?

什么是刷新?刷新就是,当用户发起一个动作,下拉刷新或加载更多,页面状态发生变化,伴随着网络请求等操作,最终完成刷新,页面状态稳定下来。动作、状态、操作,啊,这正是使用状态机的绝佳时机。

什么是状态机?根据维基百科的定义,它是“表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型”。为了实现聚合刷新操作的目的,我创建了一个状态机 YLStateMachine,它包含四个部分:StateTypeActionTypeStateMachineOperatorType 。这里我们只需关注 OperatorType

  • OperatorType

当状态发生变化时,状态机会调用 OperatorType 协议定义的方法进行一些操作。它的定义如下:

public protocol OperatorType {
    associatedtype Action: ActionType
    
    /// 开始过渡前调用
    func startTransition(_ state: Action.State)
    /// 过渡时调用
    func transition(with action: Action, completion: @escaping (Action.State) -> Void)
    /// 结束过渡后调用
    func endTransition(_ state: Action.State)
}

extension OperatorType {
    public func startTransition(_ state: Action.State) { }
    public func endTransition(_ state: Action.State) { }
}
  • RefreshOperator 与 DataSourceType

对于刷新来说,它的普通状态无非就是五种:初始状态、刷新中、还有更多数据未加载的稳定状态、加载更多中和数据已全部加载的稳定状态,当然还包含一个错误状态;而动作只有两种:下拉刷新与加载更多。要创建刷新状态机,关键在于实现遵循 OperatorType 协议的 RefreshOperator

RefreshOperator 的功能是进行一些刷新过程中的操作。刷新过程中要进行那些操作?嗯,它需要将请求来的数据模型赋予一个 model 拥有者,为此,我们定义一个 DataSourceType

public protocol DataSourceType {
    associatedtype Model: ModelType
    var model: Model? { get set }
    // 用来存储更新 target 时可能需要的信息
    var targetInfo: Any? { get set }
}

接下来,RefreshOperator 可以这样定义:

open class RefreshOperator<DataSource: DataSourceType>: OperatorType {
    typealias Action = RefreshAction
    
    public var dataSource: DataSource
    
    public init(dataSource: DataSource) {
        self.dataSource = dataSource
    }
    
    open func startTransition(_ state: RefreshState) { }
    
    open func endTransition(_ state: RefreshState) { }
    
    /// 子类必须重写此方法
    open func transition(with action: RefreshAction, completion: @escaping (RefreshState) -> Void) {
        fatalError()
    }
}

要使用刷新状态机,我们需要创建一个继承自 RefreshOperator 的子类,在其中进行网络请求并处理返回的数据,所谓聚合刷新操作,就是在这里实现的,它大概是这样的:

class SomeRefreshOperator<DS: DataSourceType>: RefreshOperator<DS> {
    
    let networkManager = NetworkManager()
    
    var target: Target = .first(page: 1)
    
    override func transition(with action: RefreshAction, completion: @escaping (RefreshState) -> Void) {
        updateTarget(when: action)
        
        networkManager.request(target: target) {
            [unowned self] (result: Result<DS.Model, Error>) in
            
            switch result {
            case .success(let model):
                switch action {
                case .pullToRefresh:
                    self.dataSource.model = model
                case .loadingMore:
                    self.dataSource.model![keyPath: model.pageablePropertyPath!] += model[keyPath: model.pageablePropertyPath!]
                }
                
                // 传递刷新状态
                let state: RefreshState = (model.nextPage == nil) ? .populated : .paginated
                completion(state)
            case .failure(let error):
                completion(self.errorHandling(error))
            }
        }
    }
    
    func updateTarget(when: RefreshAction) {
        ...
    }
    
    func errorHandling(_ error: Error) -> RefreshState {
        // 错误处理
        ...
        return .error(error)
    }
    
}

三、实现刷新操作的自动化

  • 重新审视 RefreshOperator

观察上面的 SomeRefreshOperator ,在 transition(with:completion:) 方法中,得到数据后的处理过程是模式化的,需要自主实现的有更新 target 和错误处理。错误处理是可选的,所以需要自主实现的只剩下更新 target 一项,如果我们让 target 自己提供更新的接口,RefreshOperator 就变得完全模式化,我们不必再进行自定义。为此,我们需要定义 TargetNetworkManager

  • TargetType

有些页面并没有刷新的需求,所以 target 要有一个 isRefreshable 的只读属性,用来判断页面是否可刷新;此外,RefreshOperator 要求 target 要有一个更新自身的方法。综上,TargetType 的定义如下:

public protocol TargetType: Hashable {
    /// 是否能进行下拉刷新。
    var isRefreshable: Bool { get }
    /// 更新 target
    mutating func update(with action: RefreshAction, targetInfo: Any?)
}

extension TargetType {
    public var isRefreshable: Bool { true }
    public mutating func update(with action: RefreshAction, targetInfo: Any?) { }
}
  • 更新 ModelType

说到 target,数据可能是分页的,所以我们需要更新 ModelType ,给它添加一个 nextPage 属性:

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

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

extension ModelType: Pageable { }
  • NetworkManagerType

RefreshOperator 中已经给出了 networkManager 需要实现的方法,我们这里将其协议化:

public protocol NetworkManagerType {
    associatedtype Target: TargetType
    associatedtype Model: ModelType
    associatedtype E: Error
    associatedtype R
    
    @discardableResult
    func request(target: Target, completion: @escaping (Result<Model, E>) -> Void) -> R
}
  • 重写 RefreshOperator

有了上面的基础,我们就可以对 RefreshOperator 进行重写,它的实现时这样的:

open class RefreshOperator<
    DS: DataSourceType, NM: NetworkManagerType
>: OperatorType where DS.Model == NM.Model
{
    
    public private(set) var dataSource: DS
    
    public private(set) var networkManager: NM
    
    public private(set) var target: NM.Target
    
    public init(dataSource: DS, networkManager: NM, target: NM.Target) {
        self.dataSource = dataSource
        self.networkManager = networkManager
        self.target = target
    }
    
    open func startTransition(_ state: RefreshState) { }
    
    open func endTransition(_ state: RefreshState) { }
    
    open func transition(with action: RefreshAction, completion: @escaping (RefreshState) -> Void) {
        target.update(with: action, targetInfo: dataSource.targetInfo)
        
        networkManager.request(target: target) {
            [unowned self] (result: Result<DS.Model, NM.E>) in
            
            switch result {
            case .success(let model):
                switch action {
                case .pullToRefresh:
                    self.dataSource.model = model
                case .loadingMore:
                    self.dataSource.model![keyPath: model.pageablePropertyPath!] += model[keyPath: model.pageablePropertyPath!]
                }
                
                // 传递刷新状态
                let state: RefreshState = (model.nextPage == nil) ? .populated : .paginated
                completion(state)
            case .failure(let error):
                completion(self.errorHandling(error))
            }
        }
    }
    
    open func errorHandling(_ error: Error) -> RefreshState {
        // 错误处理
        // ...
        return .error(error)
    }
    
}
  • AutoRefreshable

为了实现自动刷新,我们给 UIScrollView 添加一个自动刷新方法。有了刷新状态机,它很容易实现:

public protocol AutoRefreshable {
    func setAutoRefresh<DS: DataSourceType, NM: NetworkManagerType>(
        refreshStateMachine: StateMachine<RefreshOperator<DS, NM>>
    ) where DS.Model == NM.Model
}

extension UIScrollView: AutoRefreshable {
    public func setAutoRefresh<DS: DataSourceType, NM: NetworkManagerType>(
        refreshStateMachine: StateMachine<RefreshOperator<DS, NM>>
    ) where DS.Model == NM.Model {
        if refreshStateMachine.operator.target.isRefreshable {
            configRefreshHeader { [unowned self] in
                refreshStateMachine.trigger(.pullToRefresh) {
                    // 根据刷新状态选择展示或移除 footer
                    switch refreshStateMachine.currentState {
                    case .error:
                        // 出错了
                        ...
                    case .paginated:
                        // 有下一页
                        ...
                    default:
                        // 没有下一页了
                        ...
                    }
                }
            }
            
            configRefreshFooter { [unowned self] in
                refreshStateMachine.trigger(.loadingMore) {
                    // 根据刷新状态选择展示或移除 footer
                    switch refreshStateMachine.currentState {
                    case .populated:
                        // 没有下一页了
                        ...
                    default:
                        ...
                    }
                }
            }
        } else {
            // 不能进行刷新就没必要配置 header 与 footer 了
            refreshStateMachine.trigger(.pullToRefresh)
        }
    }
}
  • Refreshable

一个能够刷新的页面需要有些什么?首先,它需要一个可刷新的 view;其次,既然我们是以刷新状态机来进行刷新操作,它应该还需要一个刷新状态机作为属性;最后,它还需要绑定刷新状态机,以进行刷新后的数据重载操作。它长这样:

public protocol Refreshable {
    associatedtype DS: DataSourceType
    associatedtype NM: NetworkManagerType where DS.Model == NM.Model
    
    var refreshStateMachine: StateMachine<RefreshOperator<DS, NM>>! { get set }
    var refreshableView: UIScrollView? { get set }
    
    func bindRefreshStateMachine()
    func bindRefreshStateMachine(_ completion: @escaping () -> Void)
}

extension Refreshable where Self: UIViewController {
    public func bindRefreshStateMachine() {
        refreshStateMachine.completionHandler = { [weak self] in
            guard
                let self = self,
                !self.refreshStateMachine.currentState.isError
            else { return }

            if let tableView = self.refreshableView as? UITableView {
                tableView.separatorStyle = .singleLine
                tableView.reloadData()
            } else if let collectionView = self.refreshableView as? UICollectionView {
                collectionView.reloadData()
            }
        }
    }
    
    public func bindRefreshStateMachine(_ completion: @escaping () -> Void) {
        refreshStateMachine.completionHandler = { [weak self] in
            guard
                let self = self,
                !self.refreshStateMachine.currentState.isError
            else { return }

            if let tableView = self.refreshableView as? UITableView {
                tableView.separatorStyle = .singleLine
                tableView.reloadData()
            } else if let collectionView = self.refreshableView as? UICollectionView {
                collectionView.reloadData()
            }
            
            completion()
        }
    }
}
  • ViewModel

刷新操作涉及到 UITableViewDataSource ,由于第一步的努力,我们已经可以使这个过程模式化。我们定义 ViewModel 类做为所有页面的 viewModel 的基类:

open class ViewModel<Model: ModelType>:
    NSObject,
    DataSourceType,
    UITableViewDataSource
{
    // DataSourceType 的要求
    public var model: Model?
    public var targetInfo: Any?
    
    // MARK: - UITableViewDataSource
    
    open func numberOfSections(in tableView: UITableView) -> Int {
        model == nil ? 0 : model!.data.count
    }
    
    open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        model == nil ? 0 : model!.data[section].count
    }
    
    open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(for: indexPath, with: Model.tAll!)
        cell.configure(model!.data[indexPath.section][indexPath.row])
        
        return cell
    }
    
    open func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        nil
    }
    
    open func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        nil
    }
    
    open func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        false
    }
    
    open func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
        false
    }
    
    open func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        
    }
    
    open func tableView(_ tableView: UITableView, sectionForSectionIndexTitle title: String, at index: Int) -> Int {
        0
    }
    
    open func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        
    }
}

然后,创建页面的 ViewModel 时我们可以这样做:

class SomeViewModel: ViewModel<SomeModel> { }

完全不需要在里面写任何刷新的代码🥳。

  • TViewController

最后,由于 ViewController 中的操作大多也是模式化的,我们可以创建一个遵循 Refreshable 协议的 TViewController 为我们提供便利:

open class TViewController<DS: DataSourceType, NM: NetworkManagerType, RO: RefreshOperator<DS, NM>>: UIViewController, Refreshable where DS.Model == NM.Model {
    
    public var refreshableView: UIScrollView? = UITableView()
    public var refreshStateMachine: StateMachine<RefreshOperator<DS, NM>>!
    
    public convenience init(refreshOperator: RO) {
        self.init()
        
        refreshStateMachine = StateMachine(operator: refreshOperator)
        bindRefreshStateMachine()
    }
    
    public convenience init(refreshOperator: RO, afterRefreshed: @escaping () -> Void) {
        self.init()
        
        refreshStateMachine = StateMachine(operator: refreshOperator)
        bindRefreshStateMachine(afterRefreshed)
    }
    
    open override func viewDidLoad() {
        super.viewDidLoad()
        
        guard let tableView = refreshableView as? UITableView else { return }
        
        tableView.frame = view.bounds
        tableView.separatorStyle = .none
        // 防止出现多余的分割线
        tableView.tableFooterView = UIView()
        tableView.dataSource = refreshStateMachine.operator.dataSource as? UITableViewDataSource
        
        if DS.Model.tCells != nil {
            tableView.registerCells(with: DS.Model.tCells!)
        }
        
        if DS.Model.tNibs != nil {
            tableView.registerNibs(with: DS.Model.tNibs!)
        }
        
        view.addSubview(tableView)
    }
    
}

你只需简单地继承它并在 viewDidLoad() 方法中调用 refreshableViewsetAutoRefresh(refreshStateMachine:) 方法即可:

class SomeViewController: TViewController<SomeViewModel, NetworkManager<SomeModel>, RefreshOperator<SomeViewModel, NetworkManager<SomeModel>>> {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 一些自定义操作
        ...
        
        // 开始刷新
        refreshableView?.setAutoRefresh(refreshStateMachine: refreshStateMachine)
    }
}

写在最后

如果你想了解刷新自动化的更多细节,请查看 YLExtensionsYLStateMachineYLRefreshKit 的源码。由于 ViewModel 的场景路由功能,它的实现放在了另一个库 Descomposer 中。

最后,欢迎大家拥抱面向协议编程,发掘出它的更多潜力。