项目中Cell自动计算行高的实践
在前面总该说些什么
这篇文章是对团队最近关于使用Autolayout对Cell进行高度计算的总结。
接下来你将会看到以下内容:
- 数据和UI分离式的Cell高度计算的弊端
- Self-Sizing Cell的解决方案
- UITableView+FDTemplateLayoutCell简介
- UITableView+FDTemplateLayoutCell代码分析
- UITableView+FDTemplateLayoutCell集成
数据和UI分离式的Cell高度计算的弊端
通过数据模型的方式计算行高,这样的方式已经从ios4延伸到ios9了,这种方式的核心是在cell或者model里面提供一个方法,传入数据,然后根据内容+各种Margin计算出cell的高度。但是这种方法有个巨大的缺陷就是数据和界面是割裂的,你不得不在调完了UI后还要去计算高度的方法里再改一通。阅读和维护这部分逻辑时也会比较麻烦
或许已经有人想到了把计算高度的方法放到cel里面,然后把那些Margin做成常量,然后把数据传入cell来计算高度,这样当你修改内部的Margin时,高度计算的方法会不用做任何修改。不错,这个办法已经部分解决了上面的部分问题。不过当你调整UI以后对应的高度计算方法肯定会需要根据UI重写一遍,上述的问题还是存在。那有没有什么更简单的方法呢
答案是肯定的。苹果在iOS8提出了Self-Sizing Cell的概念。只要你用Autolayout对cell进行约束布局,当约束足够清晰时,Cell就能自动计算出自己的高度。如果后续UI有变化,也仅仅只需要调整控件以及相关的约束就行。
Self-Sizing Cell的解决方案
理想很丰满,现实很骨感。Self-Sizing Cell可以解决问题,但是它自身也有它自己的问题。
首先Self-Sizing Cell必须使用Autolayout布局,用Frame方式布局从iOS诞生就开始了,只要稍稍有点历史的项目,在维护中肯定会遇到用Frame布局的Cell。所以不能使用纯Autolayout布局的方案。
其次是Self-Sizing Cell本身的设计策略,导致它有一定的性能嫌疑。在iOS8之前,TableView会缓存下Cell的高度,反复滑动Cell不会重复计算;但是在iOS8以后,苹果认为Cell可能会随时改变大小(用户在设置里面调整字体什么的)所以不会做缓存了,这就导致了同一个Cell在反复滑动的时候会反复计算高度。
最后是iOS6、7、8三个版本中关于高度计算API不一致的问题:
在iOS6上,我们是在tableView:heightForRowAtIndexPath:
方法里返回通过数据计算的高度;而在在iOS7中,出现了estimatedRowHeight
相关的属性,苹果通过这个属性把计算的工作从TableView加载时延迟到了Cell出现时,提高了TableView的加载速度;在iOS8中只需要写self.tableView.estimatedRowHeight = RowHeight
这样的一行代码就能自动计算出Cell的高度(前提是Cell使用Autolayout写的布局)。可以看到随着苹果对Cell高度计算的不断优化让代码越来越简单,但是越简单的API需要的iOS版本越高,如果App要兼容低版本,就不得不在工程里写很多冗余的兼容性代码。
那如何能解决上面的这些问题呢,答案就是UITableView+FDTemplateLayoutCell
。
UITableView+FDTemplateLayoutCell简介
关于UITableView+FDTemplateLayoutCell我就直接把作者的原话扒过来。
使用
UITableView+FDTemplateLayoutCell
无疑是解决算高问题的最佳实践之一,既有 iOS8
self-sizing 功能简单的 API,又可以达到 iOS7 流畅的滑动效果,还保持了最低支持 iOS6。 使用起来大概是这样:
1
2
3
4
5 >-(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];
}]; }写完上面的代码后,你就已经使用到了:
- 和每个 UITableViewCell ReuseID 一一对应的 template layout cell
这个 cell 只为了参加高度计算,不会真的显示到屏幕上;它通过 UITableView 的-dequeueCellForReuseIdentifier:
方法 lazy 创建并保存,所以要求这个 ReuseID 必须已经被注册到了 UITableView 中,也就是说,要么是 Storyboard 中的原型 cell,要么就是使用了 UITableView 的-registerClass:forCellReuseIdentifier:
或-registerNib:forCellReuseIdentifier:
其中之一的注册方法。- 根据 autolayout 约束自动计算高度
使用了系统在 iOS6 就提供的 API:-systemLayoutSizeFittingSize:
- 根据 index path 的一套高度缓存机制
计算出的高度会自动进行缓存,所以滑动时每个 cell 真正的高度计算只会发生一次,后面的高度询问都会命中缓存,减少了非常可观的多余计算。- 自动的缓存失效机制
无须担心你数据源的变化引起的缓存失效,当调用如-reloadData
,-deleteRowsAtIndexPaths:withRowAnimation:
等任何一个触发 UITableView 刷新机制的方法时,已有的高度缓存将以最小的代价执行失效。如删除一个 indexPath 为 [0:5] 的 cell 时,[0:0] ~ [0:4] 的高度缓存不受影响,而 [0:5] 后面所有的缓存值都向前移动一个位置。自动缓存失效机制对 UITableView 的 9 个公有 API 都进行了分别的处理,以保证没有一次多余的高度计算。
UITableView+FDTemplateLayoutCell代码分析
好了,上面说了UITableView+FDTemplateLayoutCell
具有这么多功能,那这部分就来分析一下这些功能的实现方式。先说一下实现的方式路再上代码。
高度计算
UITableView+FDTemplateLayoutCell
在TableVie内部维护了一个模板Cell结构,它不会加入TableView中,模板Cell会自动调用Cell默认的方法实例化,然后根据传入的数据对这个模板进行填充,再计算高度。Cell的高度计算使用了两种方式,一种针对于Autolayout写的Cell调用[cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize]
自动计算行高;另一种是对于Frame调用[cell sizeThatFits:CGSizeMake(contentViewWidth, 0)]
手动计算行高。
缓存
基于NSDictionary的缓存。如果调用的是带缓存的API,那在计算出高度后会缓存结果,下次再计算时会先查询缓存。这个框架已经把会引起TableView重新加载的方法都做了替换,在这些方法被调用时会先清空缓存再调用以前的方法。
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration;
这个方法是高度计算的核心,API很简单,identifier
是复用的标识,configuration
主要用于你来用数据填充Cell
下面来看一下内部的代码
1 | - (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration { |
这里为了突出主干逻辑,删掉了断言还有注释以及一部分非必要逻辑。从代码上看流程非常清晰,第一行获取到模板Cell,然后调用Cell的prepareForReuse
方法来确保对Cell的调用行为和真实的一样(其实如果你所有的cell都没有在prepareForReuse
方法里面写初始化代码的话,那这一行可有可无)。接着调用configuration
来配置模板Cell,然后调用[self fd_systemFittingHeightForConfiguratedCell:templateLayoutCell]
来计算高度。
下面来深入fd_systemFittingHeightForConfiguratedCell:
这个方法
1 | - (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell { |
这个方法首先获取到Cell的宽度,得到宽度后根据是否有自定义的accessoryView来调整Cell的宽度。(这里关于关于静态数组systemAccessoryWidths的使用可能不太常见,这个是C的写法,就是静态不定长度的数组的初始化,这里面数组的长度等于花括号里面给出的最大索引值+1,其他没有给定明确初始值的都是0)然后调用 [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize]
来自动计算出Cell的高度,这里在计算之前临时添给Cell.contentView添加了一个和Cell等宽的约束是为了让内部View知道自己的父View的大小,减少因无法得知contentView的宽度而导致约束计算失败的情况。最后一步是判断Autolayout计算的结果是否正确,因为需要兼容使用Frame布局的Cell的情况,当Autolayout计算失败后再尝试着调用[cell sizeThatFits:CGSizeMake(contentViewWidth, 0)]
来计算高度。
下面来看带缓存的方法:
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByIndexPath:(NSIndexPath *)indexPath configuration:(void (^)(id cell))configuration
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByKey:(id<NSCopying>)key configuration:(void (^)(id cell))configuration
这两个方法一个是按IndexPath做缓存一个是按Key做缓存。两者都是基于NSDictionary实现的,按Key缓存比较简单,内部实现基本上可以理解为cache[Key]=rowHeight这样的形式,具体的代码就不赘述。IndexPath缓存因为涉及IndexPath数组的增删改的操作而稍稍复杂一点,不过虽然比前者复杂但是本质也是在维护一个NSDictionary,这里就不再详细介绍,而是讲一下IndexPath缓存中实现的比较有意思的地方。
1 | - (void)buildSectionsIfNeeded:(NSInteger)targetSection { |
这个方法里面有意思的地方在于当你传入的Section如果大于当前的最大Section时,会自动创建后续的所有Section,比如现在Section数组有3个元素,然后你的入参为20,那这个循环会创建剩下的17个Section数组。这里作者用到了一个NSMutableArray的一个不太常用的语法,就是NSMutableArray[最大索引值]=NewItem
,这个等价于[NSMutableArray addObject:NewItem]
,使用不太常见的语法让代码更精炼的例子在这个框架里面还有几个,可见作者OC的基本功非常扎实。
1 | - (void)fd_deleteSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation { |
这部分代码有意思的地方是作者的编程的思路。删除或者移动索引的时候可能相应的索引可能并不存在,有的人会在这里先判断,然后根据不同情况做不同的处理,这样做肯定是没问题的。不过作者在这里的做法是统统调一遍[self.fd_indexPathHeightCache buildSectionsIfNeeded:section]
保证索引肯定存在,避免了加一些冗长的判断语句,代码比显得较干净。这里把未知问题转换成已知问题的思路体现得淋漓尽致,而且特别能体现出程序设计的模块化设计与复用的美感。
好了,到这里UITableView+FDTemplateLayoutCell
的分析就写完了,可能有的人会感觉怎么才这点东西。东西确实不多,因为这个框架本身很简单,全部代码加上注释才600多行代码。不过见微知著,寥寥几百行代码体现了作者良好的编程思维以及扎实的语言基本功。这也是我们需要学习的地方。
UITableView+FDTemplateLayoutCell集成
其实最后一部分内容很少。主要是针对于老的使用Frame布局的Cell使用这个框架的实践。前面也说过如果用Autolayout的Cell是自动计算的,Frame布局的Cell是通过[cell sizeThatFits:CGSizeMake(contentViewWidth, 0)]
来做,所以需要把Frame布局的Cell的sizeThatFits
方法重载,因为sizeThatFits
方法没有入参,所以需要让Cell持有数据Model,然后在sizeThatFits
里调用以前计算高度的方法就行了。这里Cell持有数据Model可以用weak的属性来修饰,避免可能的引用循环问题。
尾声
他山之石,可以攻玉。这篇文章的核心就是基于UITableView+FDTemplateLayoutCell
的工程实践,感谢作者sunnyxx的无私奉献。sunnyxx的博客中还有不少有深度的iOS技术分析。这里贴一下他的
博客地址:http://blog.sunnyxx.com/2015/05/17/cell-height-calculation/
GitHub地址:https://github.com/forkingdog/UITableView-FDTemplateLayoutCell