UITableViewCell 自动高度

作者: mconintet | 来源:发表于2015-11-25 17:00 被阅读1900次

    UITableViewCell 自动高度

    iOS8

    由于各种天时地利的原因(OS X EI 和 Xcode 7.1.1)导致我在 google 了各种方式之后还是只能最低运行到 iOS8,所以就先从 iOS8 开始说起吧。

    首先在 iOS8 开始,系统将 Cell 的高度计算明确的分为了两种方式:

    1. 固定高度
    2. 自动高度

    固定高度

    只要一行代码就可以很简单的实现 Cell 的固定高度:

    tableView.rowHeight = /* fixed height */;
    

    不能更方便了。

    自动高度

    在 iOS8 中,Cell 的高度计算方式默认就是『自动高度』,那么怎么实现呢?其实也很简单:

    1. 为你的 Cell 设置了『合理的约束』
    2. 设置 TableView 的 estimatedRowHeight 属性

    iOS8 中 tableView.rowHeight 的默认值就是 UITableViewAutomaticDimension,所以不必设置了

    contentSize

    众所周知 UITableView 继承于 UIScrollView,那么 UITableView 就需要设置 contentSize 值。那么 UITableView 如何知道 contentSize 的值呢?

    固定高度

    如果 TableView 中的 Cell 采用的是固定高度,那么 contentSize 的高度很明显就是 fixedHeight × cellCount

    自动高度

    当采用了自动高度的话,那么系统会分别调用 Cell 上的 systemLayoutSizeFittingSize 的方法,这个方法会根据你为 Cell 设置的约束计算出 Cell 的尺寸,那么 contentSize 就会变成 dynamicallyCalculatedCellSize × cellCount

    estimatedRowHeight

    估算高度的作用是很大的,上面说到当你采用了『自动高度』的计算方式,那么系统为了知道 contentSize,别无他法的在每个 Cell 上调用实例方法计算其尺寸,当你有若干的 Cell 时,就会引发性能问题。

    为了上述的问题,系统在 TableView 上提供了 estimatedRowHeight 参数。那么我们可以看看这个『估算行高』是怎么起作用的。

    首先 TableView 是可以知道自身的 bounds 的,那么就以 bounds 为基准,至少获取能填满 bounds 的 Cells 的尺寸,对于其余的 Cells 尺寸,系统采用的是『骑驴看唱本-走着瞧』的方式。下面举几个例子大家体会下:

    1. bounds.size.height 为 570,而 estimatedRowHeight 为 20,总共的 cells 有 100 个,cells 的真实高度都八九不离十的是 22。问,最初计算的 cell's height 有多少?

    29个 = ceil( 570 / 20 )

    1. bounds.size.height 为 570,而 estimatedRowHeight 为 20,总共的 cells 有 100 个,cells 的真实高度都八九不离十的是 100。问,最初计算的 cell's height 有多少?

    仍然是 29个 = ceil( 570 / 20 )

    1. bounds.size.height 为 570,而 estimatedRowHeight 为 90,总共的 cells 有 100 个,cells 的真实高度都八九不离十的是 100。问,最初计算的 cell's height 有多少?

    7个 = ceil(570/90)

    1. bounds.size.height 为 570,而 estimatedRowHeight 为 1000,总共的 cells 有 100 个,cells 的真实高度都八九不离十的是 100。问,最初计算的 cell's height 有多少?

    6 个。因为 estimatedRowHeight 为 1000 那么系统通过计算 ceil(570 / 1000) 得出需要动态计算一个 Cell 的大小。可是问题来了,计算了 Cell 的实际高度发现只有 100,于是为了填满 bounds,必须继续计算接下来的 Cell,于是计算到填满了就不再计算。

    所以对于 estimatedRowHeight 的值,可以设置得和所有 Cell 的平均值一样,也可以设置得很大,比 TableView 的 bounds 还要大,当然前者是比较好确定的。

    那么小结下 estimatedRowHeight 的作用,就是为了加速 TableView 获取自身的 contentSize 的操作,这样尽快的将数据显示出来,然后其余的 Cells 尺寸在滑动的时候在计算。

    问题及优化

    在 iOS8 中使用了 Cell 自动高度之后,你会发现,只要一个 Cell 需要被显示到屏幕上,它的高度都会被计算一次,即使这个 Cell 在之前的滑动中已经被计算过高度了。之所以被设计成这样的原因系统认为 Cell 的高度是随时可能改变的,比如在设置中改变了字体大小:

    如果在 iOS7 中使用了自动高度,你就会发现一旦 Cell 在之前被计算过高度,那么它下一次滑动出来时就不会被计算高度了。这是因为从 iOS7 开始,iOS7 中引入了 Dynamic Type 的功能,这个功能使得用户可以调整应用中字体的大小,而 iOS7 中的所有系统应用都适配了这个功能需求。但是从 iOS8 开始,Apple 希望所有的应用都可以适配这个功能需求,于是就取消了 Cell 在自动算高时的高度缓存。

    于是如你所见,在 iOS8 中由于没有了自动的高度缓存,那么在使用自动高度时,Cell 的高度会被多次计算,这样就会导致滑动不流畅。其实这不是大的问题,Apple 为了把 Cell 的高度计算变得更灵活,使得是否动态计算高度 or 使用缓存已计算的高度的工作放到了开发者这边,还是很符合设计模式的,只不过开发者使用有些麻烦了。

    优化的方式其实说起来也是很简单的,就是对于已经计算了高度的 Cell,只要确信它的高度是不会再变化的,那么就将这个高度缓存起来,下回在系统向你所要 Cell 高度时(heightForRowAtIndexPath),返回那个之间计算过的高度缓存就行了。

    iOS7

    其实 iOS7 中使用 Cell 自动高度没有什么好讨论的了,系统会自动的为我们缓存已经计算过的 Cell 高度。唯一要注意的是在 iOS7 中需要显式的设置:

    tableView.rowHeight = UITableViewAutomaticDimension;
    

    其他还是和在 iOS8 中一样的:你为 Cell 设置了『合理的约束』,让 TabaleView 使用自动 Cell 高度计算,剩下的系统就为了做了。

    iOS6

    完全没接触过不清楚

    怎么缓存

    上面已经说了在 iOS8 中我们需要自己决定是否缓存那些已经计算过的 Cell 高度。那么我们应该如何缓存呢?有两点很重要:

    1. 缓存的 Key 如何决定
    2. 使用什么作为 Cache Storage

    Key

    因为需要通过 Key 去取回 Cell 已经计算过的 Height,那么 Key 需要可以标识出各个 Cell。我们可以选取既可以标识 Cells 又可以区别它们之间不同的属性来作为 Key。对于一个 Objc 对象,它们之间最显著的不同肯定是它们的 memory address 了,而且需要获取 Objc 对象的内存地址也很简单:

    NSString *temp = @"123";
    uintptr_t ptrAddress = (uintptr_t) temp;
    

    但是,请回忆我们在使用 TableView 和 Cell 时常用的方法:

    - dequeueReusableCellWithIdentifier:forIndexPath:
    - dequeueReusableCellWithIdentifier:
    

    就是它俩使得 TableView 中 Cells 都是 Reused。所以通过 memory address 的方式是不行了。剩下的唯一可用的方式就是 indexPath 了 😂。

    Cache Storage

    选什么作为 Cache Storage 呢?可用的有:

    • NSMutableArray
    • NSCache
    • objc_setAssociatedObject

    先看看它们之间读写性能的差别,主要代码来自这儿,我加上了 NSMutableArray 部分:

    #import <Foundation/Foundation.h>
    #import <objc/runtime.h>
    
    void logTimeSpentExecutingBlock(dispatch_block_t block, NSString* label)
    {
        NSTimeInterval then = CFAbsoluteTimeGetCurrent();
        block();
        NSTimeInterval now = CFAbsoluteTimeGetCurrent();
        NSLog(@"Spent %.5f seconds on %@", now - then, label);
    }
    
    @interface Test : NSObject {
    @public
        NSString* ivar;
    }
    @property (nonatomic, strong) NSString* ordinary;
    @end
    
    @interface Test (Runtime)
    @property (nonatomic, strong) NSString* runtime;
    @end
    
    @implementation Test
    
    - (void)setOrdinary:(NSString*)ordinary
    {
        // the default implementation checks if the ivar is already equal
        _ordinary = ordinary;
    }
    
    @end
    
    @implementation Test (Runtime)
    
    - (NSString*)runtime
    {
        return objc_getAssociatedObject(self, @selector(runtime));
    }
    
    - (void)setRuntime:(NSString*)string
    {
        objc_setAssociatedObject(self, @selector(runtime), string, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    
    @end
    
    int main(int argc, const char* argv[])
    {
        @autoreleasepool
        {
            Test* test = [Test new];
            int iterations = 1000000;
    
            NSCache* cache = [[NSCache alloc] init];
            NSMutableArray* arr = [[NSMutableArray alloc] init];
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    test->ivar = @"foo";
                }
            }, @"writing ivar");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    test->ivar;
                }
            }, @"reading ivar");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    test.ordinary = @"foo";
                }
            }, @"writing ordinary");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    [test ordinary];
                }
            }, @"reading ordinary");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    test.runtime = @"foo";
                }
            }, @"writing runtime");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    [test runtime];
                }
            }, @"reading runtime");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    [cache setObject:@"1" forKey:@(i)];
                }
            }, @"writing NSCache");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    [cache objectForKey:@(i)];
                }
            }, @"reading NSCache");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    [arr addObject:@"1"];
                }
            }, @"writing NSMutableArray");
    
            logTimeSpentExecutingBlock(^{
                for (int i = 0; i < iterations; i++) {
                    arr[i];
                }
            }, @"reading NSMutableArray");
        }
        return 0;
    }
    

    输出的结果:

    Spent 0.00408 seconds on writing ivar
    Spent 0.00177 seconds on reading ivar
    Spent 0.02587 seconds on writing ordinary
    Spent 0.01329 seconds on reading ordinary
    Spent 0.06314 seconds on writing runtime
    Spent 0.04348 seconds on reading runtime
    Spent 1.26897 seconds on writing NSCache
    Spent 0.29358 seconds on reading NSCache
    Spent 0.02913 seconds on writing NSMutableArray
    Spent 0.01621 seconds on reading NSMutableArray
    

    好了可以淘汰 NSCache 了。看到 objc_set/get 性能和 NSMutableArray 是差不多的,那么选择哪一个呢?

    其实这是要根据我们的业务需求的,对于存 Height 它俩都可以完成,但是我们知道 Cells 是需要可以 Delete/Insert 的,那么问题来了,如果有了 Delete/Insert 操作,而我们的 Key 是根据的 indexPath,那么缓存中的 Key 就『不准』了,需要进行相应的调整。而使用 NSMutableArray 当你 Delete/Insert 时它会自动的为我们将操作索引的后续索引进行调整。

    所以如果我们需要使用自己的缓存,需要这样:

    1. 决定合适的 Cache Key
    2. 选取合适的 Cache Storage
    3. Delete/Insert 发生时调整缓存数据

    所有这些还是有点麻烦的,所以大概的原理知道了就可以开始使用别人的劳动成果了 UITableView-FDTemplateLayoutCell 😆

    对了开头的『合理的约束』是什么可以在这里找到 About self-satisfied cell

    相关文章

      网友评论

      • 01b85ef5c24a:29个 = ceil( 570 / 20 ) 这个“个”是像素高度还是什么 不是很懂
      • 花轮丨:哈哈 你真是思路清晰人又逗。 emoji表情用的恰到好处!
      • 75c446df3208:看了不少计算高度的文章,你这个算是最清晰了,赞一个
      • 曾樑:这个挺实用的:+1::+1:
        mconintet:@曾樑 谢谢捧场 :grin:

      本文标题:UITableViewCell 自动高度

      本文链接:https://www.haomeiwen.com/subject/mxzrhttx.html