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

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

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

同构复用cell的页面

同构复用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的页面

异构不复用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,这样只要拖拽一下,然后填坑就好了,大大提高了效率。

screenshot.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容

  • 1.badgeVaule气泡提示 2.git终端命令方法> pwd查看全部 >cd>ls >之后桌面找到文件夹内容...
    i得深刻方得S阅读 4,628评论 1 9
  • iOS网络架构讨论梳理整理中。。。 其实如果没有APIManager这一层是没法使用delegate的,毕竟多个单...
    yhtang阅读 5,164评论 1 23
  • 概述在iOS开发中UITableView可以说是使用最广泛的控件,我们平时使用的软件中到处都可以看到它的影子,类似...
    liudhkk阅读 8,985评论 3 38
  • 看到这梳子,想到小时候姑姑总是用这样鲜艳的梳子给我梳头发!
    露米西阅读 189评论 0 0
  • 1、你发来的每条信息,都是我从你正在输入时就在等待的事情。 2、 我一直觉得,一个人想要的存在感,说到底,还是和自...
    然小仙阅读 300评论 0 0