UITableViewCell 自动高度

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

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

推荐阅读更多精彩内容