这篇文章是我和咱们团队最近对 UITableViewCell 利用 AutoLayout 自动高度计算和 UITableView 滑动优化的一个总结。
咱们也在维护一个开源的扩展,UITableView+FDTemplateLayoutCell,让高度计算这个事情变的史无前例的简单,也受到了不少星星的支持,github连接请戳我 git
这篇总结你能够读到: github
UITableView是咱们再熟悉不过的视图了,它的 delegate 和 data source 回调不知写了多少次,也难免遇到 UITableViewCell 高度计算的事。UITableView 询问 cell 高度有两种方式。
一种是针对全部 Cell 具备固定高度的状况,经过: 数组
1 |
self.tableView.rowHeight = 88; |
上面的代码指定了一个全部 cell 都是 88 高度的 UITableView,对于定高需求的表格,强烈建议使用这种(而非下面的)方式保证没必要要的高度计算和调用。rowHeight属性的默认值是 44,因此一个空的 UITableView 显示成那个样子。 缓存
另外一种方式就是实现 UITableViewDelegate 中的: 网络
1 2 3 |
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { // return xxx } |
须要注意的是,实现了这个方法后,rowHeight 的设置将无效。因此,这个方法适用于具备多种 cell 高度的 UITableView。 ide
这个属性 iOS7 就出现了, 文档是这么描述它的做用的: 函数
If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time. 工具
恩,听上去蛮靠谱的。咱们知道,UITableView 是个 UIScrollView,就像平时使用 UIScrollView 同样,加载时指定 contentSize 后它才能根据本身的 bounds、contentInset、contentOffset 等属性共同决定是否能够滑动以及滚动条的长度。而 UITableView 在一开始并不知道本身会被填充多少内容,因而询问 data source 个数和建立 cell,同时询问 delegate 这些 cell 应该显示的高度,这就形成它在加载的时候浪费了多余的计算在屏幕外边的 cell 上。和上面的 rowHeight 很相似,设置这个估算高度有两种方法: oop
1 2 3 4 5 |
self.tableView.estimatedRowHeight = 88; // or - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { // return xxx } |
有所不一样的是,即便面对种类不一样的 cell,咱们依然可使用简单的 estimatedRowHeight 属性赋值,只要总体估算值接近就能够,好比大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就能够估算一个 66,基本符合预期。 布局
说完了估算高度的基本使用,能够开始吐槽了:
具备动态高度内容的 cell 一直是个头疼的问题,好比聊天气泡的 cell, frame 布局时代一般是用数据内容反算高度:
1 |
CGFloat height = textHeightWithFont() + imageHeight + topMargin + bottomMargin + ...; |
供 UITableViewDelegate 调用时极可能是个 cell 的类方法:
1 2 3 |
@interface BubbleCell : UITableViewCell + (CGFloat)heightWithEntity:(id)entity; @end |
各类魔法 margin 加上耦合了屏幕宽度。
AutoLayout 时代好了很多,提供了-systemLayoutSizeFittingSize:的 API,在 contentView 中设置约束后,就能计算出准确的值;缺点是计算速度确定没有手算快,并且这是个实例方法,须要维护专门为计算高度而生的 template layout cell,它还要求使用者对约束设置的比较熟练,要保证 contentView 内部上下左右全部方向都有约束支撑,设置不合理的话计算的高度就成了0。
这里还不得不提到一个 UILabel 的蛋疼问题,当 UILabel 行数大于0时,须要指定 preferredMaxLayoutWidth 后它才知道本身何时该折行。这是个“鸡生蛋蛋生鸡”的问题,由于 UILabel 须要知道 superview 的宽度才能折行,而 superview 的宽度还依仗着子 view 宽度的累加才能肯定。这个问题好像到 iOS8 才可以自动解决(不过咱们找到了解决方案)
回到正题,iOS8 WWDC 中推出了 self-sizing cell 的概念,旨在让 cell 本身负责本身的高度计算,使用 frame layout 和 auto layout 均可以享受到:
这个特性首先要求是 iOS8,要是最低支持的系统版本小于8的话,还得针对老版本单写套老式的算高(囧),不过用的 API 到不是新面孔:
1 2 |
self.tableView.estimatedRowHeight = 213; self.tableView.rowHeight = UITableViewAutomaticDimension; |
这里又不得不吐槽了,自动计算 rowHeight 跟 estimatedRowHeight 究竟是有什么仇,若是不加上估算高度的设置,自动算高就失效了- -
PS:iOS8 系统中 rowHeight 的默认值已经设置成了 UITableViewAutomaticDimension,因此第二行代码能够省略。
问题:
相同的代码在 iOS7 和 iOS8 上滑动顺畅程度彻底不一样,iOS8 莫名奇妙的卡。很大一部分缘由是 iOS8 上的算高机制大不相同,这是我作的小测试:
研究后发现这么屡次额外计算有下面的缘由:
iOS8 把高度计算搞成这个样子,从 WWDC 也却是能找到点解释,cell 被认为随时均可能改变高度(如从设置中调整动态字体大小),因此每次滑动出来后都要从新计算高度。
说了这么多,究竟有没有既能省去算高烦恼,又能保证顺畅的滑动,还能支持 iOS6+ 的一站式解决方案呢?
使用 UITableView+FDTemplateLayoutCell 无疑是解决算高问题的最佳实践之一,既有 iOS8 self-sizing 功能简单的 API,又能够达到 iOS7 流畅的滑动效果,还保持了最低支持 iOS6。
使用起来大概是这样:
1 2 3 4 5 6 7 |
#import <UITableView+FDTemplateLayoutCell.h> - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return [tableView fd_heightForCellWithIdentifier:@"identifer" cacheByIndexPath:indexPath configuration:^(id cell) { // 配置 cell 的数据源,和 "cellForRow" 干的事一致,好比: cell.entity = self.feedEntities[indexPath.row]; }]; } |
写完上面的代码后,你就已经使用到了:
咱们在设计这个工具的 API 时斟酌了很是长的时间,既要保证功能的强大,也要保证接口的精简,一行调用背后隐藏着不少功能。
这一套缓存机制能对滑动起多大影响呢?除了肉眼能明显的感知到外,我还作了个小测试:
一个有 54 个内容和高度不一样 cell 的 table view,从头滑动到尾,再从尾滑动到头,iOS8 系统下,iPhone6,使用 Time Profiler 监测算高函数所花费的时间:
未使用缓存API、未使用估算,共花费 877 ms:
使用缓存API、开启估算,共花费 77 ms:
测试数据的精度先无论,从量级上就差了一个数量级,说实话本身也没想到差距有这么大- -
同时,工具也顺手解决了-preferredMaxLayoutWidth的问题,在计算高度前向 contentView 加了一条和 table view 宽度相同的宽度约束,强行让 contentView 内部的控件知道了本身父 view 的宽度,再反算本身被外界约束的宽度,破除“鸡生蛋蛋生鸡”的问题,这里比较 tricky,就不展开说了。下面说说利用 RunLoop 预缓存的实现。
FDTemplateLayoutCell 的高度预缓存是一个优化功能,它要求页面处于空闲状态时才执行计算,当用户正在滑动列表时显然不该该执行计算任务影响滑动体验。
通常来讲,这个功能要耦合 UITableView 的滑动状态才行,但这种实现十分不优雅且可能破坏外部的 delegate 结构,但好在咱们还有RunLoop这个工具,了解它的运行机制后,能够用很简单的代码实现上面的功能。
在曾经的 RunLoop 线下分享会(视频可戳)中介绍了 RunLoopMode 的概念。
当用户正在滑动 UIScrollView 时,RunLoop 将切换到 UITrackingRunLoopMode 接受滑动手势和处理滑动事件(包括减速和弹簧效果),此时,其余 Mode (除 NSRunLoopCommonModes 这个组合 Mode)下的事件将所有暂停执行,来保证滑动事件的优先处理,这也是 iOS 滑动顺畅的重要缘由。
当 UI 没在滑动时,默认的 Mode 是 NSDefaultRunLoopMode(同 CF 中的 kCFRunLoopDefaultMode),同时也是 CF 中定义的 “空闲状态 Mode”。当用户啥也不点,此时也没有什么网络 IO 时,就是在这个 Mode 下。
注册 RunLoopObserver 能够观测当前 RunLoop 的运行状态,并在状态机切换时收到通知:
由于“预缓存高度”的任务须要在最无感知的时刻进行,因此应该同时知足:
使用 CF 的带 block 版本的注册函数可让代码更简洁:
1 2 3 4 5 6 7 |
CFRunLoopRef runLoop = CFRunLoopGetCurrent(); CFStringRef runLoopMode = kCFRunLoopDefaultMode; CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler (kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) { // TODO here }); CFRunLoopAddObserver(runLoop, observer, runLoopMode); |
在其中的 TODO 位置,就能够开始任务的收集和分发了,固然,不能忘记适时的移除这个 observer
假设列表有 20 个 cell,加载后展现了前 5 个,那么开启估算后 table view 只计算了这 5 个的高度,此时剩下 15 个就是“预缓存”的任务,而咱们并不但愿这 15 个计算任务在同一个 RunLoop 迭代中同步执行,这样会卡顿 UI,因此应该把它们分别分解到 15 个 RunLoop 迭代中执行,这时就须要手动向 RunLoop 中添加 Source 任务(由应用发起和处理的是 Source 0 任务)
Foundation 层没对 RunLoopSource 提供直接构建的 API,可是提供了一个间接的、既熟悉又陌生的 API:
1 2 3 4 5 |
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray *)array; |
这个方法将建立一个 Source 0 任务,分发到指定线程的 RunLoop 中,在给定的 Mode 下执行,若指定的 RunLoop 处于休眠状态,则唤醒它处理事件,简单来讲就是“睡你xx,起来嗨!”
因而,咱们用一个可变数组装载当前全部须要“预缓存”的 index path,每一个 RunLoopObserver 回调时都把第一个任务拿出来分发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
NSMutableArray *mutableIndexPathsToBePrecached = self.fd_allIndexPathsToBePrecached.mutableCopy; CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler (kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) { if (mutableIndexPathsToBePrecached.count == 0) { CFRunLoopRemoveObserver(runLoop, observer, runLoopMode); return; } NSIndexPath *indexPath = mutableIndexPathsToBePrecached.firstObject; [mutableIndexPathsToBePrecached removeObject:indexPath]; [self performSelector:@selector(fd_precacheIndexPathIfNeeded:) onThread:[NSThread mainThread] withObject:indexPath waitUntilDone:NO modes:@[NSDefaultRunLoopMode]]; }); |
这样,每一个任务都被分配到下个“空闲” RunLoop 迭代中执行,其间但凡是有滑动事件开始,Mode 切换成 UITrackingRunLoopMode,全部的“预缓存”任务的分发和执行都会自动暂定,最大程度保证滑动流畅。
若是你以为这个工具能帮获得你,整合到工程也十分简单。
使用 cocoapods:
1 |
pod search UITableView+FDTemplateLayoutCell |
写这篇文章时的最新版本为 1.2,去除了前一个版本的黑魔法,增长了预缓存功能。
欢迎使用和支持这个工具,有 bug 请随时反馈哦~
再复习下 github 地址: https://github.com/forkingdog/UITableView-FDTemplateLayoutCell