项目中Cell自动计算行高的实践

项目中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
2
3
4
5
6
7
8
9
10
11
12
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration {

UITableViewCell *templateLayoutCell = [self fd_templateCellForReuseIdentifier:identifier];

[templateLayoutCell prepareForReuse];

if (configuration) {
configuration(templateLayoutCell);
}

return [self fd_systemFittingHeightForConfiguratedCell:templateLayoutCell];
}

这里为了突出主干逻辑,删掉了断言还有注释以及一部分非必要逻辑。从代码上看流程非常清晰,第一行获取到模板Cell,然后调用Cell的prepareForReuse方法来确保对Cell的调用行为和真实的一样(其实如果你所有的cell都没有在prepareForReuse方法里面写初始化代码的话,那这一行可有可无)。接着调用configuration来配置模板Cell,然后调用[self fd_systemFittingHeightForConfiguratedCell:templateLayoutCell]来计算高度。

下面来深入fd_systemFittingHeightForConfiguratedCell:这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
- (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell {
CGFloat contentViewWidth = CGRectGetWidth(self.frame);


if (cell.accessoryView) {
contentViewWidth -= 16 + CGRectGetWidth(cell.accessoryView.frame);
} else {
static const CGFloat systemAccessoryWidths[] = {
[UITableViewCellAccessoryNone] = 0,
[UITableViewCellAccessoryDisclosureIndicator] = 34,
[UITableViewCellAccessoryDetailDisclosureButton] = 68,
[UITableViewCellAccessoryCheckmark] = 40,
[UITableViewCellAccessoryDetailButton] = 48
};
contentViewWidth -= systemAccessoryWidths[cell.accessoryType];
}


CGFloat fittingHeight = 0;

if (!cell.fd_enforceFrameLayout && contentViewWidth > 0) {

NSLayoutConstraint *widthFenceConstraint = [NSLayoutConstraint constraintWithItem:cell.contentView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:contentViewWidth];
[cell.contentView addConstraint:widthFenceConstraint];

fittingHeight = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;
[cell.contentView removeConstraint:widthFenceConstraint];

}

if (fittingHeight == 0) {
fittingHeight = [cell sizeThatFits:CGSizeMake(contentViewWidth, 0)].height;
}


return fittingHeight;
}

这个方法首先获取到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
2
3
4
5
6
7
8
9
- (void)buildSectionsIfNeeded:(NSInteger)targetSection {
[self enumerateAllOrientationsUsingBlock:^(FDIndexPathHeightsBySection *heightsBySection) {
for (NSInteger section = 0; section <= targetSection; ++section) {
if (section >= heightsBySection.count) {
heightsBySection[section] = [NSMutableArray array];
}
}
}];
}

这个方法里面有意思的地方在于当你传入的Section如果大于当前的最大Section时,会自动创建后续的所有Section,比如现在Section数组有3个元素,然后你的入参为20,那这个循环会创建剩下的17个Section数组。这里作者用到了一个NSMutableArray的一个不太常用的语法,就是NSMutableArray[最大索引值]=NewItem,这个等价于[NSMutableArray addObject:NewItem],使用不太常见的语法让代码更精炼的例子在这个框架里面还有几个,可见作者OC的基本功非常扎实。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)fd_deleteSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation {
[sections enumerateIndexesUsingBlock:^(NSUInteger section, BOOL *stop) {
[self.fd_indexPathHeightCache buildSectionsIfNeeded:section];
[self.fd_indexPathHeightCache enumerateAllOrientationsUsingBlock:^(FDIndexPathHeightsBySection *heightsBySection) {
[heightsBySection removeObjectAtIndex:section];
}];
}];
}

- (void)fd_moveSection:(NSInteger)section toSection:(NSInteger)newSection {
[self.fd_indexPathHeightCache buildSectionsIfNeeded:section];
[self.fd_indexPathHeightCache buildSectionsIfNeeded:newSection];
[self.fd_indexPathHeightCache enumerateAllOrientationsUsingBlock:^(FDIndexPathHeightsBySection *heightsBySection) {
[heightsBySection exchangeObjectAtIndex:section withObjectAtIndex:newSection];
}];
}

这部分代码有意思的地方是作者的编程的思路。删除或者移动索引的时候可能相应的索引可能并不存在,有的人会在这里先判断,然后根据不同情况做不同的处理,这样做肯定是没问题的。不过作者在这里的做法是统统调一遍[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