再来谈谈iOS的table view(动态高度计算)

作为一名沉溺于文艺和逗比生活的另类程序员,写技术博客一直就不是我的爱好或者觉得该做的事。

不得不吐槽一下CSDN博客的体验,当我下午3点已经完成这篇文章的80%时,点了一下保存按钮,结果神奇的发现只保存了半小时以前的工作状态,新写的东西全没有save掉。当时已经有砍人的冲动了。。。。结果还是坚持重写了一遍。

以下是正文:

今天刚好同事问了我autolayout下table view 动态高度cell的计算方式,想想还是写篇文章整理一下。之前XCode群里的不少朋友也问过这个问题,希望你们能读读我写的这篇文章,至少能够解决一些问题。(看完文章之后如果觉得有不妥或者笔误之处请留言或私信我的新浪微博@Saber | 断了弦的吉他 | iOS程序渣 我是不介意你来粉一下我的,反正粉也少的可怜)


相信Table View这东西是每个iOS程序员再熟悉不过的东西了。不过每当iOS版本一更新的时候,总会有些更新是大家没去关注的,结果出现了各种各样的问题(致命的bug等)。

这篇文章主要就讲讲如何在iOS7 / 8下面做动态的cell高度的计算(比如微信朋友圈这种)该如何去做。由于普适性问题,还是用Obj-C去做示例了,想要swift的请自行脑补转换代码。。。。


First of all,先挖点坟,讲讲table view的动态高度计算的演变过程吧(个人code转变历程)。

计算高度的核心delegate selector就是这个方法:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath

这个方法的作用是,在每次table view初始化的时候,会调用dataSource.count次,计算出所有cell的高度。并且,在table view滚动的时候,也进行计算调用。


这时,table view 的delegate调用顺序是:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
- (UITableViewCell *)cellForRowAtIndexPath:(NSIndexPath *)indexPath;            

看到这里,你是不是也发现了一个致命的问题?没错,对于布局相对复杂的cell来说,每次计算的开销实在太大了,尤其是那个还在使用256M内存iPhone 4的时代。这样做的结果就是导致了用户在滚动table的时候出现了卡顿的现象。

开发者可以用这样的办法来solve这个问题:

1.创建一个cell height pool,目的是缓存第一次计算出来的height

2.每次在调用heightForRow的时候,先从height pool中查询是否有值,如有,直接return这个值;反之,则计算一个新的,存储到height pool中去。

3.当didReceiveMemoryWarning时,清空缓存池。


是的,这样解决了table在滚动时,由于计算开销导致滚动卡顿的问题。


然而这一切并没有什么卵用。


由于heightForRow这个方法在每次table初始化的时候,将dataSource.count个数的cell高度全部计算出来,导致当cell个数繁多时,进入页面的速度还是有明显的延时。

在iOS7的时候,苹果终于承认了table自身在动态计算高度时候的性能瓶颈,聪明的苹果工程师设计了这个方法:

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(7_0);


这个delegate解决问题的思路是,只有当table cell需要被display的时候,才会将cell的高度计算出来,而其余不需要展示的cell只需要给一个预估的高度即可,当需要display的时候再去计算。


在iOS7下,当你使用了estimate的delegate时,调用顺序变成了这样:

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(7_0);
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
- (UITableViewCell *)cellForRowAtIndexPath:(NSIndexPath *)indexPath;  
值得注意的是,estimated这个方法还是会调用dataSource.count次,但是由于返回的是一个固定值,速度大大提升了。

然后,heightForRow会被调用一屏能够展示的cell数量次,进行必要地高度计算;

最后cellForRow进行了cell的实例化。


而在iOS8下,delegate的调用顺序则又有所改变:

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(7_0);
- (UITableViewCell *)cellForRowAtIndexPath:(NSIndexPath *)indexPath;
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath  

estimated的调用顺序没有被改变,但是,iOS8下会先调用cellForRow进行实例化,而且每调用一次,就会紧接着调用heightForRow的方法,根据cell的indexPath驱动每次高度的计算。

举一个形象的例子来区别两个系统版本调用顺序的区别:两排相同人数的人进行12报数。iOS7的报数模式是第一排人连续报1,然后第二排在报2,你看到的结果是这样:1111 2222

而iOS8下,报数模式则换成了第一排一个人报1,紧接着第二排的人又报2,你看到的结果就是12 12 12 12

这些不同,决定了我们在适配低版本系统时候,代码编写的不同。


再来扯扯如何计算动态的高度:


在iOS6的autolayout问世之前,开发者们都要通过frame的方式进行布局(现在很多老项目依旧是用frame去布局)

当需要用到动态高度的时候,往往都是使用手动写代码计算高度的方式进行计算。

虽然我本人非常厌恶这种方式,但也不得不使用)

当然,有人计算的方式很优雅,有的人的代码看起来很笨重。

比较常见的的方式是在table cell的子类中定义通过model来计算高度的类方法:

+ (CGFloat)cellHeightWithModel:(id)model;

通过输入的数据值来进行cell高度的动态计算,并在delegate中return这个类方法的返回值即可,具体的计算参照乃们的业务需求。

但在autolayout问世之后,一直致力于改变纯代码编程的习惯苹果(个人是这么理解的。。。请各位Geek轻喷),让一切变得简单了很多。


让我们来看一个很low的东西该如何实现:

再来谈谈iOS的table view(动态高度计算)_第1张图片


在iOS6之前,或者习惯纯代码进行编程的人,肯定会想到使用:

- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(NSDictionary *)attributes context:(NSStringDrawingContext *)context 
加上define的padding等macro计算出需要的高度,然后用上面阐述的方法,返回最终计算出来的值。


但在当你使用xib / storyboard配合autolayout进行编程时,这一切都不需要你去写大量计算的代码

(就像我一直觉得用纯代码去写各种控件是比较笨的行为,当然有人会反驳我会提高编译速度,

减少整个项目包的大小,但我依旧热爱轻松的编程方式,就像我使用sublime远觉得比vim舒服一样,

尤其是在苹果对xib和sb优化的如此美妙的情况下)。


当然,在动态计算高度时候,iOS7与8还是有很大的区别,先以iOS7为例:


iOS7的好处不言而喻,因为iOS7的系统版本,只会在iphone 5s以前的机型中出现,也就是说,我们需要适配的屏幕宽度只有320 point(640px)。

换句话说,我们的label的preferredMaxLayoutWidth可以设置成Explicit的固定值,你可以选择在xib / storyboard上进行设置,也可以使用代码。

再来谈谈iOS的table view(动态高度计算)_第2张图片

所谓preferredMaxLayoutWidth,是决定label换行的一个最大宽度,当label达到了这个最大宽度就会换行,autolayout也是据此算出我们的label.numberOfLines = 0时,需要的实际高度了。

可能有人遇到Label的preferredMaxLayoutWidth在iOS7下Automatic的warning,此问题stack over flow上有很好地解答:stack over flow,在这里顺便share下:

再来谈谈iOS的table view(动态高度计算)_第3张图片


设置好了autolayout大概就是这个样子的:

再来谈谈iOS的table view(动态高度计算)_第4张图片
然后我们的代码大概就是这个样子:

- (UITableViewCell *)getCellFromIndexPath:(NSIndexPath *)indexPath{
    
    static NSString*cellID = @"cellID";
    CustomTableViewCell *cell = [self.tableview dequeueReusableCellWithIdentifier:cellID forIndexPath:indexPath];
    Model*model = _dataArray[indexPath.row];
    [cell reloadDataWithModel:model];
    return cell;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return [self getCellFromIndexPath:indexPath];
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    
    CustomTableViewCell *cell = (CustomTableViewCell *)[self getCellFromIndexPath:indexPath];
    
    CGSize size = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
    
    return size.height + 1;

}
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath{
    return UITableViewAutomaticDimension;
}

systemLayoutSizeFittingSize,这个方法会根据autolayout计算出实际的高度,所以在设置约束的时候,一定要保证约束正确。

它有两种参数:

UIKIT_EXTERN const CGSize UILayoutFittingCompressedSize NS_AVAILABLE_IOS(6_0);//紧凑,大小适中的最小尺寸
UIKIT_EXTERN const CGSize UILayoutFittingExpandedSize NS_AVAILABLE_IOS(6_0);//宽松
这里我们需要的当然是最小的尺寸,使用 UILayoutFittingCompressedSize


值得注意的:

1.每次在计算height的时候,我们都重新去创建了一个cell。虽然通过reuse identifier的方式帮助我们减少了内存的开销,但是相对的,每次重复创建(double次)会导致性能问题。为了保证性能和内存之间的一个平衡,个人推荐使用cell缓存池的方式配合reuse identifier(这个以后有时间再来说明吧)。

2.在iOS8.4之前,table cell的content view一直要比cell的高度小1个point,这是用来填充我们的分割线的,由于我们计算的大小是根据label的super view,也就是content view计算的,所以会在delegate里面return的height+1。

但在最新的iOS8.4中,content view和cell的高度已经变得一致了(没关系,在iOS8中,我们有更优雅地方式来计算自动布局)


没错,你发现使用autolayout之后,你没有写几行代码(对于一些程序员来说,这是不可接受的事情,O(∩_∩)O)

在iOS8下,我们的方式更加简单,只需在table初始化的时候:

    self.tableview.rowHeight = UITableViewAutomaticDimension;
    self.tableview.estimatedRowHeight = 60.f;


只需要设置我们的rowHeight和预估的高度就可以了,甚至不用去实现那些在iOS7中已经简单到不行的delegate。在iOS8中,苹果称之为self sizing cell。


当然,你也可以使用iOS7中的方式,使用delegate来实现此类效果,但是由于ip6和ip6+的出现,label的preferredMaxLayoutWidth也变得不同,此时,我们会选择继承UILabel来实现我们自己的一个Label类

#import "PreferedWidthLabel.h"

@implementation PreferedWidthLabel

- (void)setBounds:(CGRect)bounds {
    [super setBounds:bounds];
        
    if (self.numberOfLines == 0 && bounds.size.width != self.preferredMaxLayoutWidth) {
        self.preferredMaxLayoutWidth = self.bounds.size.width;
        [self setNeedsUpdateConstraints];
    }
}

@end
从代码上来看,我们只是overide了setBounds这个方法,并改变了这个label的 preferredMaxLayoutWidth和它目前的bounds.size.width保持一致。


对于简单地动态高度cell,这些已经足够了。

但是,了解了这些,想实现一个类似朋友圈的效果还远远不够。

我们知道,朋友圈的效果包括了动态数量的照片,动态数量的评论,点赞,而且cell的样子 包括了转载、音乐、纯文字状态、图文混排、只有图片的状态等多种样式

这些东西不是只用autolayout的self sizing就能够解决,往往还是需要我们手动用代码去计算的。

一个可行的方法是,写多个类型的cell,定义一个cell的枚举来决定加载cell的cell style,参考UITableCell的style(笔者以前曾用过此方法)。

当然我更会推荐使用UICollectionView的collection view layout来实现复杂的布局效果(后面我会再写一篇关于collection view layout的文章),但那仅仅支持iOS6+。像微信以前一样支持iOS4.3+(现在也需要iOS7.0以上了)的app市面上并不多见(其实是这样奇葩的产品经理不多见吧 哈哈 这绝对会导致程序员的抓狂了),可是作为程序员,了解一下代码的变革过程还是很有意思的,能够深入地了解苹果工程师智慧的转变,以便于我们更好地应用这些代码。


总之,写这篇文章的目的还是希望帮助大家理解如何借助autolayout优雅地实现动态的行高。(当碰到不是特别复杂的布局时,就不需要写那么多计算的代码啦)


再次想吐槽CSDN害我写了两遍,祝各位安好,差不多搬完砖我也回家洗洗睡了。





你可能感兴趣的:(iOS开发)