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

你可能感兴趣的:(UITableViewCell 高度自动计算)