阿里云iOS App提高UITableView页面开发效率的一些经验

iOS App的页面应该尽可能使用UITableView来做,好处实在太多,在为什么要基于UITableview构建UI这篇文章有详细阐述,在这里就不赘述了。

归纳总结一下,UITableView页面应该分为同构复用cell异构不复用cell两种。这两种页面细节有诸多不同,需要针对地做一些设计。阿里云App针对这两种页面做了不同的设计,大大提高了开发效率。下面就这两种页面的细节做一些讲述。

同构复用cell的页面

阿里云iOS App提高UITableView页面开发效率的一些经验_第1张图片

阿里云iOS App提高UITableView页面开发效率的一些经验_第2张图片

阿里云iOS App提高UITableView页面开发效率的一些经验_第3张图片
阿里云iOS App提高UITableView页面开发效率的一些经验_第4张图片

同构复用cell的页面主要的困难点是缓存、下拉刷新、上拉获取更多、翻页逻辑、空白页和错误页的显示等逻辑非常容易出错。为了消除这些逻辑的复杂性,我们设计了ALYUIViewControllerRefreshDataProtocol协议,定义如下所示。

@objc public protocol ALYUIViewControllerRefreshDataProtocol : UITableViewDataSource, UITableViewDelegate {

    // 有多个页面,可上拉获取更多
    @objc optional func aly_fetchDataForMultiPageTableView(_ pageNum : UInt,
                                                     actionType: ALYLoadDataActionType,
                                                     successCallback : @escaping GetPageDataSuccessCallback,
                                                     failedCallback : @escaping GetPageDataFailedCallback)
    // 只有单个页面
    @objc optional func aly_fetchDataForSinglePageTableView(_ actionType: ALYLoadDataActionType,
                                                      successCallback : @escaping GetPageDataSuccessCallback,
                                                      failedCallback : @escaping GetPageDataFailedCallback)

    //空白页
    @objc optional func aly_viewForNoDataTableViewController() -> UIView?

    //错误页
    @objc optional func aly_viewForErrorTableViewController() -> UIView?

    /*
     * data source是否为空
     * 当接口存在缓存的时候,first load使用缓存的数据,页面正常显示了。
     * 但是接口出了问题,接着错误页会覆盖当前页面。
     * 为了避免这种问题,在有数据的情况下,不要需要显示错误页。
    */
    @objc optional func aly_isDataSourceEmpty() -> Bool
}

页面上不同的数据加载需要做不同的处理,所以我们定义下面几种数据操作类型。

@objc public enum ALYLoadDataActionType : Int {
    case firstLoad = 0          //第一次载入数据,使用缓存
    case refresh                //普通下拉刷新等,不使用缓存
    case refreshWithIndicator   //需要切换数据源的刷新,比如ECS换了region,域名换了group,需要转菊花。
    case fetchMore              //上拉获取更多,不使用缓存
}

在扩展里面实现所有的逻辑。主要实现了fetchData和fetchMore函数,里面有缓存开关的设置、翻页控制、空白页和错误的显示控制。

extension UIViewController {

    public var aly_hasMultiplePageInTableView : Bool {
        get {
            return self.responds(to: #selector(ALYUIViewControllerRefreshDataProtocol.aly_fetchDataForMultiPageTableView(_:actionType:successCallback:failedCallback:)))
        }
    }

    //MARK: 刷新组件
    fileprivate func enablePullToRefresh() {
        self.aly_tableView.addPull {[weak self] () -> Void in
            self?.aly_refreshData()
        }

        self.aly_tableView.showPullToRefresh = true
    }

    fileprivate func enablePullToGetMore() {
        self.aly_tableView.addInfiniteScrolling {[weak self] () -> Void in

            //上拉获取更多要调用 aly_fetchMore
            self?.aly_fetchMore()
        }

        self.aly_tableView.showsInfiniteScrolling = true
    }

    //MARK: 网络请求
    fileprivate func aly_fetchData(_ actionType: ALYLoadDataActionType) {

        if actionType == .firstLoad || actionType == .refreshWithIndicator {
            self.aly_firstLoadingView?.startAnimating()
        }

        if actionType != .firstLoad {
            self.aly_hideErrorView()
            self.aly_hideNoDataView()
        }

        if let _self = self as? ALYUIViewControllerRefreshDataProtocol {

            let successCallback = {

                [weak self] (dataNum : UInt) -> Void in

                self?.aly_firstLoadingView?.stopAnimating()
                self?.aly_tableView.stopRefreshAnimation()
                self?.aly_hideErrorView()

                //默认会开启下拉刷新
                self?.enablePullToRefresh();

                if dataNum == 0 {
                    self?.aly_tableView.reloadData()
                    self?.aly_showNoDataView()
                    return
                } else {
                    self?.aly_hideNoDataView()
                }

                //默认有多页
                if self?.aly_hasMultiplePageInTableView == true {
                    if let pagesize = self?.aly_pageSize, dataNum >= pagesize {
                        self?.aly_currentPageNum = 2

                        //拉取到足够多的页数才开始向上拉取更多功能
                        self?.enablePullToGetMore()

                        self?.aly_tableView.showsInfiniteScrolling = true
                    } else {

                        self?.aly_tableView.showsInfiniteScrolling = false
                    }
                } else {
                    self?.aly_tableView.showsInfiniteScrolling = false
                }

                self?.aly_tableView.reloadData()
            }

            let failedCallback = {

                [weak self] (exception : NSException) -> Void in

                self?.aly_firstLoadingView?.stopAnimating()
                self?.aly_tableView.stopRefreshAnimation()
                self?.showFailureToast(exception.reason)

                if actionType == .firstLoad
                    || actionType == .refresh
                    || actionType == .refreshWithIndicator {

                    if let aly_isDataSourceEmpty = _self.aly_isDataSourceEmpty {

                        //域名管理页有点特殊,tableView至少有一个cell,需要实现aly_isDataSourceEmpty接口
                        if aly_isDataSourceEmpty() == true {
                            self?.aly_showErrorView()
                        }
                    } else if let count = self?.aly_tableView.visibleCells.count, count > 0 { //一般情况下走这个分支

                    } else {
                        self?.aly_showErrorView()
                    }
                }

                self?.enablePullToRefresh();
            }

            if self.aly_hasMultiplePageInTableView == true {
                self.aly_currentPageNum = 1
                _self.aly_fetchDataForMultiPageTableView?(self.aly_currentPageNum, actionType: actionType, successCallback: successCallback, failedCallback: failedCallback)
            }else {
                _self.aly_fetchDataForSinglePageTableView?(actionType, successCallback: successCallback, failedCallback: failedCallback)
            }
        }
    }

    fileprivate func aly_fetchMore() {
        let successCallback = {

            [weak self] (dataNum : UInt) -> Void in

            self?.aly_tableView.infiniteScrollingView.stopAnimating()

            if dataNum == self?.aly_pageSize {
                self?.aly_currentPageNum += 1
                self?.aly_tableView.showsInfiniteScrolling = true
            } else {
                self?.aly_tableView.showsInfiniteScrolling = false
            }

            self?.aly_tableView.reloadData()
        }

        let failedCallback = {

            [weak self] (exception : NSException) -> Void in

            self?.aly_tableView.infiniteScrollingView.stopAnimating()
            self?.aly_tableView.reloadData()
            self?.showFailureToast(exception.reason)
        }

        if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
            _self.aly_fetchDataForMultiPageTableView?(self.aly_currentPageNum, actionType: ALYLoadDataActionType.fetchMore, successCallback: successCallback, failedCallback: failedCallback)
        }
    }

    public func aly_firstLoad() {
        self.aly_enableFirstLoading = true
        self.aly_fetchData(ALYLoadDataActionType.firstLoad)
    }
    
    public func aly_refreshData(_ actionType: ALYLoadDataActionType = .refresh) {
        self.aly_fetchData(actionType)
    }

    //MARK: 空白页显示逻辑
    open func aly_showNoDataView() {

        if self.aly_noDataView == nil {

            var noDataView : UIView! = nil

            //如果实现协议,就从协议里面获取空白页。否则给一个默认的空白页。
            if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
                noDataView = _self.aly_viewForNoDataTableViewController?()
            }

            if noDataView == nil {
                noDataView = ALYNoDataView()
            }

            self.aly_tableView.addSubview(noDataView)
            self.aly_noDataView = noDataView
        }

        if self.aly_noDataView != nil {
            self.aly_noDataView!.snp.makeConstraints({ (make) -> Void in
                make.top.equalTo(self.aly_tableView)
                make.leading.equalTo(self.aly_tableView)
                make.width.equalTo(self.view)
                make.height.equalTo(self.view)
            })
        }
    }

    open func aly_hideNoDataView() {

        self.aly_noDataView?.removeFromSuperview()
        self.aly_noDataView = nil
    }

    //MARK: 错误页显示逻辑
    open func aly_showErrorView() {

        if self.aly_errorView == nil {

            var errorView : UIView! = nil
            if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
                errorView = _self.aly_viewForErrorTableViewController?()
            }

            if errorView == nil {
                errorView = ALYErrorView()
            }

            self.aly_tableView.addSubview(errorView)
            self.aly_errorView = errorView
        }

        if self.aly_errorView != nil {
            self.aly_errorView!.snp.makeConstraints({ (make) -> Void in
                make.top.equalTo(self.aly_tableView)
                make.leading.equalTo(self.aly_tableView)
                make.width.equalTo(self.view)
                make.height.equalTo(self.view)
            })
        }
    }

    open func aly_hideErrorView() {

        self.aly_errorView?.removeFromSuperview()
        self.aly_errorView = nil
    }

有了这套机制之后,实现一个全功能的页面简直太方便了,最大的工作量可能是写cell吧。

不足之处

开发者仍然需要管理dataSource,网络请求返回数据后,做一些dataSource的增加和替换工作。虽然代码逻辑是一样的,但是还是比较繁琐,新手容易犯错误。

异构不复用cell的页面

阿里云iOS App提高UITableView页面开发效率的一些经验_第5张图片

阿里云iOS App提高UITableView页面开发效率的一些经验_第6张图片

阿里云iOS App提高UITableView页面开发效率的一些经验_第7张图片

异构不复用cell的页面一般用于设置这样的简单页面,在App里面占比也是很高的。开发这种页面的主要困难点是cell千奇百怪,点击的动作也各不相同。为了解决这个问题,我们设计了CellObject,把数据、状态和cell上的视觉元素放到一起,用起来代码如下所示。避免在cellForRow里面写大量的if/else代码。

lazy var myActivityItem : ALYStandardCellObject = {

    let item = ALYStandardCellObject.Builder()
        .icon(name: "my_activity")
        .text("我的云栖大会", color: UIColor.aly_black())
        .selectionAction(select: { (sender) in

        //do something
    }).build()

    return item
}()

比如ALYStandardCellObject的实现就是把icon、imageView、text、textLabel放到一起,所有相关的因素放到一起。

open class ALYStandardCellObject : ALYCellObject {

    //对应UITableViewCell的imageView属性
    public var icon : UIImage? {

        didSet {
            self.imageView?.image = icon
        }
    }

    public lazy var imageView : UIImageView? = {

        let imageView = UIImageView()
        return imageView
    }()

    //对应UITableViewCell的textLabel属性
    public var text : String? {

        didSet {
            self.textLabel.text = text
        }
    }

    public var textColor : UIColor? {

        didSet {
            self.textLabel.textColor = textColor
        }
    }

    public lazy var textLabel : UILabel = {

        let textLabel = UILabel()
        textLabel.font = UIFont.aly_f7()
        textLabel.textColor = UIColor.aly_black()
        return textLabel
    }()
}

因为CellObject非常紧凑,UITableViewDataSource和UITableViewDelegate相关的逻辑就非常简单了,代码都是一样的,只要抄一下就好了。

func numberOfSections(in tableView: UITableView) -> Int {
    return self.dataSource.count
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return self.dataSource[section].count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = ALYCommonCell()
    cell.bindObject(self.dataSource[indexPath.section][indexPath.row])

    return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)

    if let cell = tableView.cellForRow(at: indexPath) as? ALYCommonCell {
        if let block = cell.object?.didSelectblock {
            block(cell)
        }
    }
}

func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    return section == 0 ? 0.001 : 10
}

func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
    let rows = self.dataSource[section].count
    let object = self.dataSource[section][rows-1]

    return object.promptViewHeight
}

func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
    let rows = self.dataSource[section].count
    let object = self.dataSource[section][rows-1]

    return object.promptView
}

因为页面一般比较简单,所以不用考虑cell的复用,数据和状态都维护在CellObject里面,所以很多时候数据有更新,并不需要reload data,直接设置CellObject里面的视觉元素就好了,非常之方便。

fileprivate func updateUnpayOrderCount() {

    if ALYSessionManager.isLogin() {
        let request = ALYRpcRequestObject()
            .setApiName("xxx")
            .setUseCache(true)
            .setApiVersion("1.0")
        ALYRPC.handyJsonModel(ALYCommonIntResultModel.self).asyncCall(request) { [weak self] (success, result, error) in
            if success {
                if let s = result?.intValue {
                    self?.orderItem.badgeNumber = s
                }
            }
        }
    }
}

Builder

异构cell因为元素比较多,所以构造函数比较复杂,为了解决这个问题,我们使用Builder模式,把相同的设置放到一起,用起来也是非常方便的。

lazy var myActivityItem : ALYStandardCellObject = {

    let item = ALYStandardCellObject.Builder()
        .icon(name: "my_activity")
        .text("我的云栖大会", color: UIColor.aly_black())
        .selectionAction(select: { (sender) in

        //do something
    }).build()

    return item
}()

子类化

ALYStandardCellObject对应标准的UITableViewCell,如果稍微有些不一样的,还是需要子类化的。比如加上小红点、消息数量等。

class ALYOrderCellObject : ALYStandardCellObject {

    class Builder: ALYStandardCellObject.Builder {

        override func build() -> ALYOrderCellObject {
            let instance = ALYOrderCellObject()
            self.assignClosures.forEach { (assign) in
                assign(instance)
            }
            return instance
        }

        func badge(number: Int) -> Self {
            self.assignClosures.append({ any in
                (any as! ALYOrderCellObject).badgeNumber = number
            })
            return self
        }
    }

    var badgeNumber : Int = 0 {
        didSet {

            self.layoutBadgeLabel()
        }
    }
    var badgeWidthConstraint : Constraint?

    lazy var badgeLabel : UILabel = {
        let label = UILabel()
        label.layer.backgroundColor = UIColor.aly_fromHex(0xf15533).cgColor
        label.textAlignment = .center
        label.textColor = UIColor.aly_ct_7()
        label.font = UIFont.aly_f10()
        label.layer.masksToBounds = true
        label.layer.cornerRadius = 9

        return label
    }()

    override func bindCell(_ cell: UITableViewCell) {
        super.bindCell(cell)
        cell.contentView.addSubview(self.badgeLabel)

        self.badgeLabel.snp.remakeConstraints({ (make) in
            make.centerY.equalToSuperview()
            make.trailing.equalTo(0)
            self.badgeWidthConstraint = make.width.equalTo(0).constraint
            make.height.equalTo(18)
        })

        self.layoutBadgeLabel()
    }

    func layoutBadgeLabel() {
       //do layout
    }
}

扩展

有些cell可能要改accessory view,这种情况就更加简单了,只要扩展一下ALYStandardCellObject就行。

extension ALYStandardCellObject {
    // 适配PKYStepper控件
    func adaptStepper(_ decorateBlock: (ALYPKYStepper) -> Void, callbackBlock: @escaping ALYPKYStepperValueChangedCallback) {
        let stepper = ALYPKYStepper()

        decorateBlock(stepper)
        stepper.valueChangedCallback = callbackBlock

        self.accessoryView = stepper
        var frame = stepper.frame;
        frame.size.width = 111
        frame.size.height = 30 //UITableViewCell.aly_getCellHeight(.OneLine)
        stepper.frame = frame
    }
}

Xcode snippet

为了避免大家写大量的重复代码,我们写了两个Xcode snippet,这样只要拖拽一下,然后填坑就好了,大大提高了效率。

阿里云iOS App提高UITableView页面开发效率的一些经验_第8张图片
screenshot.png

你可能感兴趣的:(阿里云iOS App提高UITableView页面开发效率的一些经验)