UITableViewCell 高度自动计算

前言:iOS开发中,UITableview的使用场景是非常广泛的,随着业务逐渐复杂,对于cell的高度的处理也是越来越麻烦的,有的时候甚至会非常痛苦。

一、常规解决方案

当前主流的做法是在model中计算一次然后缓存在model中,以提升体验和性能,至于计算的时机,可以在model解析完成后计算,也可以在heightForRowAt indexPath调用时再计算,都可以,是可以解决问题,不过存在弊端:

  • 做这些高度的计算非常的麻烦,麻烦随复杂度提升,会出现很多的if-else

  • 高度计算并不精确

    a. 计算label高度 不精确
    b. 计算attributeString高度 不精确
    c. ...

  • 为了计算高度,做了许多额外的事情,额外的内存开销(ps:计算过的人都懂的

  • 如果是在model解析完成后计算,那首次加载和下一页时,会出现卡顿

  • 如果在heightForRowAt indexPath调用时计算,cell首次被渲染会出现卡顿

二、Cell 高度自动计算

于是需要一个小工具,可以自动计算cell的高度,来规避这些麻烦。

cell高度计算考虑的点:

  • 1.计算原理:使用数据设置完cell后,强制布局self.layoutIfNeeded(),然后获取高度

  • 2.计算的方式:

    a. 最初的思路是,直接拿整个cell的话,遍历所有的子视图,循环累加
    b. 后来觉得没有必要做一次循环,使用者传入一个用来计算指定的位于底部的视图,用这视图的y值加上height得到的就是cell的高度了,也方便一些复杂cell中各种隐藏和显示的使用,也略微提升性能

  • 3.保证性能:每个cell必须只计算一次,换句话来说,需要有缓存的功能

  • 4.场景覆盖:

    a. 有些cell不一样的状态,需要显示不一样的内容,此时的高度很有可能不一样,需要支持
    b. 一些带操作事件的按钮,执行完一些操作后,cell的元素可能出现增减,此时很可能需要重新计算高度,需要支持

  • 5.其他:cell之间一般都是有间距的,以卡片风格为例,其实真正需要关注的只是卡片的高度,而实际高度是需要加上间距的,需要支持一下这种类型,算出实际的高度之后加上一个偏移值,其他场景可能也需要,默认值为0

三、源码

为UITableview扩展两个属性,用于实现缓存功能:

  • cacheHeightDictionary:缓存cell行高的DictionarykeymodelJSONString或指定的其他唯一标识,value为自动计算好的行高
  • cacheCellDictionary:缓存用来获取或计算行高的cell,保证性能(理论上只需要一个cell来计算行高即可,降低消耗)
/*
 TableViewCell 使用 SnapKit 布局 自动计算行高并缓存
*/
public extension UITableView {
    /// 缓存 cell 行高 的 DIC(key为model的JSONString或指定的其他唯一标识,value为自动计算好的行高)
    var cacheHeightDictionary: NSMutableDictionary? {
        get {
            let dict = objc_getAssociatedObject(self, &kUITableViewSnapKitCellCacheHeightDictKey) as? NSMutableDictionary
            if let cache = dict {
                return cache
            }
            let newDict = NSMutableDictionary()
            objc_setAssociatedObject(self, &kUITableViewSnapKitCellCacheHeightDictKey, newDict, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            return newDict
        }
    }
    
    /// 缓存用来获取或计算行高的cell,保证性能(理论上只需要一个cell来计算行高即可,降低消耗)
    var cacheCellDictionary: NSMutableDictionary? {
        get {
            let dict = objc_getAssociatedObject(self, &kUITableViewSnapKitCellCacheHeightReuseCellsKey) as? NSMutableDictionary
            if let cache = dict {
                return cache
            }
            let newDict = NSMutableDictionary()
            objc_setAssociatedObject(self, &kUITableViewSnapKitCellCacheHeightReuseCellsKey, newDict, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            return newDict
        }
    }
}

UITableViewCell扩展两个属性,用于实现高度的计算:

  • hpc_lastViewInCell:所指定的距离cell底部较近的参考视图,必须指定,若不指定则会assert失败
  • hpc_bottomOffsetFromLastViewInCell:可选设置的属性,表示cell的高度需要从指定的lastViewInCell需要偏移多少,默认为0,小于0也为0
/**
 TableViewCell 使用 Masonry布局 自动计算 cell 行高 category
 
 -- UI布局必须放在UITableViewCell的初始化方法中:- initWithStyle:reuseIdentifier:
 */
public extension UITableViewCell {
    
    // 可选设置的属性,表示cell的高度需要从指定的lastViewInCell需要偏移多少,默认为0,小于0也为0
    @objc var hpc_bottomOffsetFromLastViewInCell: CGFloat {
        
        get {
            if let number = objc_getAssociatedObject(self, &kBottomOffsetFromLastViewInCellKey) as? NSNumber {
                return CGFloat(number.floatValue)
            }
            return 0.0
        }
        
        set {
            objc_setAssociatedObject(self, &kBottomOffsetFromLastViewInCellKey, NSNumber(value: Float(newValue)), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
    
    /// 所指定的距离cell底部较近的参考视图,必须指定,若不指定则会assert失败
    var hpc_lastViewInCell: UIView? {
        
        get {
            let lastView = objc_getAssociatedObject(self, &kLastViewInCellKey)
            return lastView as? UIView
        }
        
        set {
            objc_setAssociatedObject(self, &kLastViewInCellKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
}

计算逻辑:

extension UITableViewCell {
    /// 带缓存功能,自动计算行高
    ///
    /// - Parameters:
    ///   - tableView: 目标tableview
    ///   - config:    计算行高配置回调
    ///   - cache:     缓存参数(key,唯一指定key【可以是model的id,或者model的JSONString】,stateKey,行高状态【可选】,shouldUpdate,【可选,默认false,是否要更新指定stateKey中缓存高度,若为true, 不管有没有缓存,都会重新计算)
    /// - Returns: 行高
    public static func cellHeight(forTableView tableView: UITableView,
                                     config: ((_ cell: UITableViewCell) -> Void)?,
                                     updateCacheIfNeeded cache: (() -> (key: String, stateKey: String?, shouldUpdate: Bool?))?) -> CGFloat {
        //  有缓存则从缓存中取
        if let cacheBlock = cache {
            let keyGroup     = cacheBlock()
            let key          = keyGroup.key
            let stateKey     = keyGroup.stateKey ?? kSnapKitCellCacheStateDefaultKey
            let shouldUpdate = keyGroup.shouldUpdate ?? false
            if shouldUpdate == false {
                if let cacheDict = tableView.cacheHeightDictionary,
                    let stateDict = cacheDict[key] as? NSMutableDictionary, // 状态高度缓存
                    let height = stateDict[stateKey] as? NSNumber {
                    if height.intValue != 0 {
                        return CGFloat(height.floatValue)
                    }
                }
            }
        }
        
        let className = self.description()
        var cell = tableView.cacheCellDictionary?.object(forKey: className) as? UITableViewCell
        if cell == nil {
            if Thread.isMainThread {
                cell = self.init(style: .default, reuseIdentifier: nil)
            } else {
                // 这里在第一次计算时,可能会卡住主线程,需要优化,考虑使用信号量 @山竹
                DispatchQueue.main.sync {
                    cell = self.init(style: .default, reuseIdentifier: nil)
                }
            }
            tableView.cacheCellDictionary?.setObject(cell!, forKey: className as NSCopying)
        }
        
        if let block = config { block(cell!) }
        //  添加子线程处理
        var height: CGFloat = 0
        if Thread.isMainThread {
            height = cell!.calculateCellHeight(forTableView: tableView, updateCacheIfNeeded: cache)
        } else {
            DispatchQueue.main.sync {
                height = cell!.calculateCellHeight(forTableView: tableView, updateCacheIfNeeded: cache)
            }
        }
        return height
    }
    
    /// 获取缓存高度并缓存
    ///
    /// - Parameters:
    ///   - tableView: 目标tableview
    ///   - cache:     缓存参数
    /// - Returns: 高度
    private func calculateCellHeight(forTableView tableView: UITableView,
                                         updateCacheIfNeeded cache: (() -> (key: String, stateKey: String?, shouldUpdate: Bool?))?) -> CGFloat {
        
        assert(self.hpc_lastViewInCell != nil, "hpc_lastViewInCell property can't be nil")
        self.layoutIfNeeded()
        var height = self.hpc_lastViewInCell!.frame.origin.y + self.hpc_lastViewInCell!.frame.size.height
        height += self.hpc_bottomOffsetFromLastViewInCell
        if let cacheBlock = cache {
            let keyGroup = cacheBlock()
            let key      = keyGroup.key
            let stateKey = keyGroup.stateKey ?? kSnapKitCellCacheStateDefaultKey
            if let cacheDict = tableView.cacheHeightDictionary {
                // 状态高度缓存
                let stateDict = cacheDict[key] as? NSMutableDictionary
                if stateDict != nil {
                    stateDict?[stateKey] = NSNumber(value: Float(height))
                } else {
                    cacheDict[key] = NSMutableDictionary(object: NSNumber(value: Float(height)), forKey: stateKey as NSCopying)
                }
            }
        }
        return height
    }
}

核心代码只有三处,其他都是一些判断,是否有缓存,有的话从缓存中取,没有的话,计算高度,然后缓存起来:

    1. 初次调用,会new一个cell出来,用来计算高度,然后缓存起来,用于下次调用
let className = self.description()
var cell = tableView.cacheCellDictionary?.object(forKey: className) as? UITableViewCell
if cell == nil {
    if Thread.isMainThread {
        cell = self.init(style: .default, reuseIdentifier: nil)
    } else {
        // 这里在第一次计算时,可能会卡住主线程,需要优化,考虑使用信号量 @山竹
        DispatchQueue.main.sync {
            cell = self.init(style: .default, reuseIdentifier: nil)
        }
    }
    tableView.cacheCellDictionary?.setObject(cell!, forKey: className as NSCopying)
}
    1. cell实例通过回调给调用者用于渲染cell
回调出去:
if let block = config { block(cell!) }
此处为使用使用的地方,config回调,得到的就是用于计算高度的cell实例:

OrderListCell.cellHeight(forTableView: tableView, config: { (targetCell) in
    if let cell = targetCell as? OrderListCell {
        cell.configCell(itemModel, self.viewModel.orderType, self.viewModel.orderState, indexPath.section)
    }
}, updateCacheIfNeeded: { () -> (key: String, stateKey: String?, shouldUpdate: Bool?) in
    return (itemModel.cellHeightCacheKey.0 + "+\(indexPath.section)+\(indexPath.row)", nil, itemModel.cellHeightCacheKey.1)
})
    1. 计算高度,通过强制布局刷新,用指定用来计算的子视图的y加上功能height,然后加上用户定义的偏移值,就是cell需要的实际高度
self.layoutIfNeeded()
var height = self.hpc_lastViewInCell!.frame.origin.y + self.hpc_lastViewInCell!.frame.size.height
height += self.hpc_bottomOffsetFromLastViewInCell

四、使用

    1. cellui初始化时,指定hpc_lastViewInCell
// 自动高度计算
hpc_lastViewInCell = countLabel
hpc_bottomOffsetFromLastViewInCell = 6
    1. 在渲染cell的地方,如果有需要,需要修改hpc_lastViewInCell的值,有些复杂cell中各种组合非常多,底部子视图不确定是哪一个,是需要修改hpc_lastViewInCell的,有些底部子视图是固定的cell则不用
// 以下为自动高度计算代码
var lastView: UIView = countLabel
var offset: CGFloat = 6

if !(model.expectDeliveryTime ?? "").isEmpty {
    if model.haveMaterial {
        lastView = checkMaterialViewWithDelivery
        offset = 8
    } else {
        lastView = expectDeliveryLabel
    }
} else {
    lastView = checkMaterialView
    offset = 8
}

hpc_lastViewInCell = lastView
hpc_bottomOffsetFromLastViewInCell = offset
    1. heightForRowAt indexPath方法中,调用自动高度计算,有些特殊需求,有一个最小高度,小于最小高度则使用最小高度,反之则使用计算的高度,没有这种需求的,直接返回计算的高度就好
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    if viewModel.showItemArray?.count ?? 0 > indexPath.section, viewModel.showItemArray?[indexPath.section].tradeOrderTOList?.count ?? 0 > indexPath.row,
        let itemModel = viewModel.showItemArray?[indexPath.section].tradeOrderTOList?[indexPath.row] {
        // 自动高度计算
        let calculateHeight = OrderListCell.cellHeight(forTableView: tableView, config: { (targetCell) in
            if let cell = targetCell as? OrderListCell {
                cell.configCell(itemModel, self.viewModel.orderType, self.viewModel.orderState, indexPath.section)
            }
        }, updateCacheIfNeeded: { () -> (key: String, stateKey: String?, shouldUpdate: Bool?) in
            return (itemModel.cellHeightCacheKey.0 + "+\(indexPath.section)+\(indexPath.row)", nil, itemModel.cellHeightCacheKey.1)
        })
    
        return calculateHeight > itemModel.cellHeight ? calculateHeight : itemModel.cellHeight
    }
    return 0
}

五、性能

以下是第一页20条数据计算所花费的时间,第一条数据由于要new一个cell实例出来,耗时略长,其他的都是很少的,对性能无任何影响:

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

推荐阅读更多精彩内容