我们知道,tableView的heightForRowAtIndexPath方法是调用最多的,反复在里面进行大量的重复计算消耗额外性能是不必要也不合理的。UITableView+FDTemplateLayoutCell
就是抓住这一点做了缓存的优化,我们来看下他是怎么处理的吧!
1. 调用
我们在Demo中可以看到,有三种模式:
#pragma mark - UITableViewDelegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
FDSimulatedCacheMode mode = self.cacheModeSegmentControl.selectedSegmentIndex;
switch (mode) {
case FDSimulatedCacheModeNone:
return [tableView fd_heightForCellWithIdentifier:@"FDFeedCell" configuration:^(FDFeedCell *cell) {
[self configureCell:cell atIndexPath:indexPath];
}];
case FDSimulatedCacheModeCacheByIndexPath:
return [tableView fd_heightForCellWithIdentifier:@"FDFeedCell" cacheByIndexPath:indexPath configuration:^(FDFeedCell *cell) {
[self configureCell:cell atIndexPath:indexPath];
}];
case FDSimulatedCacheModeCacheByKey: {
FDFeedEntity *entity = self.feedEntitySections[indexPath.section][indexPath.row];
return [tableView fd_heightForCellWithIdentifier:@"FDFeedCell" cacheByKey:entity.identifier configuration:^(FDFeedCell *cell) {
[self configureCell:cell atIndexPath:indexPath];
}];
};
default:
break;
}
}
对应的方法说明:
// 返回计算好的高度(无缓存)
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration;
// 返回计算好的高度,并根据 indexPath 内部创建与之相应的二维数组缓存高度
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByIndexPath:(NSIndexPath *)indexPath configuration:(void (^)(id cell))configuration;
// 返回计算好的高度,内部创建一个字典缓存高度并由使用者指定 key
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByKey:(id<NSCopying>)key configuration:(void (^)(id cell))configuration;
引用原作者的话,如果调用了fd_heightForCellWithIdentifier: cacheByIndexPath: configuration:;
那么会就:
- (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 没有滑动的空闲时刻执行,计算和缓存那些还没有显示到屏幕中的 cell,整个缓存过程完全没有感知,这使得完整列表的高度计算既没有发生在加载时,又没有发生在滑动时,同时保证了加载速度和滑动流畅性,下文会着重讲下这块的实现原理。
2. 文件结构
实现可分为4个模块:
- UITableView+FDTemplateLayoutCell //提供高度获取方法
- UITableView+FDIndexPathHeightCache //通过NSIndexPath进行缓存高度
- UITableView+FDKeyedHeightCache //通过key值进行缓存高度
- UITableView+FDTemplateLayoutCellDebug //提供Debug打印
3. 源码分析
首先我们来看这个类:UITableView (FDIndexPathHeightCacheInvalidation):
+ (void)load {
//对 9 个触发 UITableView 刷新机制的公有方法分别进行了处理,保证缓存数组的正确
SEL selectors[] = {
@selector(reloadData),
@selector(insertSections:withRowAnimation:),
@selector(deleteSections:withRowAnimation:),
@selector(reloadSections:withRowAnimation:),
@selector(moveSection:toSection:),
@selector(insertRowsAtIndexPaths:withRowAnimation:),
@selector(deleteRowsAtIndexPaths:withRowAnimation:),
@selector(reloadRowsAtIndexPaths:withRowAnimation:),
@selector(moveRowAtIndexPath:toIndexPath:)
};
//拿到SEL总字节数除以SEL的字节数(8个字节)得到方法数
for (NSUInteger index = 0; index < sizeof(selectors) / sizeof(SEL); ++index) {
//遍历交互对应的方法实现
SEL originalSelector = selectors[index];
SEL swizzledSelector = NSSelectorFromString([@"fd_" stringByAppendingString:NSStringFromSelector(originalSelector)]);
Method originalMethod = class_getInstanceMethod(self, originalSelector);
Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
//类似的几个方法都是清除fd_indexPathHeightCache中指定的sections和cell
- (void)fd_reloadData {
if (self.fd_indexPathHeightCache.automaticallyInvalidateEnabled) {
[self.fd_indexPathHeightCache enumerateAllOrientationsUsingBlock:^(FDIndexPathHeightsBySection *heightsBySection) {
[heightsBySection removeAllObjects];
}];
}
FDPrimaryCall([self fd_reloadData];);
}
然后我们直接从fd_heightForCellWithIdentifier: cacheByIndexPath: configuration:;
该方法入手看他的调用:
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByIndexPath:(NSIndexPath *)indexPath configuration:(void (^)(id cell))configuration {
if (!identifier || !indexPath) {
return 0;
}
// 1.检测缓存中是否存在,UITableView+FDIndexPathHeightCache会判断当前是横屏还是竖屏返回对应的缓存二维数组,用于存放[indexPath.section][indexPath.row],为-1则返回NO
if ([self.fd_indexPathHeightCache existsHeightAtIndexPath:indexPath]) {
[self fd_debugLog:[NSString stringWithFormat:@"hit cache by index path[%@:%@] - %@", @(indexPath.section), @(indexPath.row), @([self.fd_indexPathHeightCache heightForIndexPath:indexPath])]];
//2.如果命中缓存,则拿到对应的self.heightsBySectionForCurrentOrientation[indexPath.section][indexPath.row]返回高度
return [self.fd_indexPathHeightCache heightForIndexPath:indexPath];
}
//3.如果不存在,则根据identifier取到对应的cell并且使用systemLayoutSizeFittingSize方法计算出高度
CGFloat height = [self fd_heightForCellWithIdentifier:identifier configuration:configuration];
//4.将高度保存到self.heightsBySectionForCurrentOrientation[indexPath.section][indexPath.row] = @(height);
[self.fd_indexPathHeightCache cacheHeight:height byIndexPath:indexPath];
[self fd_debugLog:[NSString stringWithFormat: @"cached by index path[%@:%@] - %@", @(indexPath.section), @(indexPath.row), @(height)]];
return height;
}
//根据identifier和configuration 计算获得 cell 高度
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration {
if (!identifier) {
return 0;
}
// 调用fd_templateCellForReuseIdentifier:方法获取UITableViewCell
UITableViewCell *templateLayoutCell = [self fd_templateCellForReuseIdentifier:identifier];
//手动调用prepareForReuse方法, 确保一致性。这个方法调用时机在cell滑出屏幕的时候进行调用
[templateLayoutCell prepareForReuse];
//执行回掉函数, 用以给cell设置显示的样式以及内容
if (configuration) {
configuration(templateLayoutCell);
}
//返回高度
return [self fd_systemFittingHeightForConfiguratedCell:templateLayoutCell];
}
//// 调用fd_templateCellForReuseIdentifier:方法获取UITableViewCell
- (__kindof UITableViewCell *)fd_templateCellForReuseIdentifier:(NSString *)identifier {
NSAssert(identifier.length > 0, @"Expect a valid identifier - %@", identifier);
//懒加载templateCellsByIdentifiers 字典, 该字典用于存储cell重用标识符与cell的键值对
NSMutableDictionary<NSString *, UITableViewCell *> *templateCellsByIdentifiers = objc_getAssociatedObject(self, _cmd);
if (!templateCellsByIdentifiers) {
templateCellsByIdentifiers = @{}.mutableCopy;
objc_setAssociatedObject(self, _cmd, templateCellsByIdentifiers, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
//通过identifier获取cell
UITableViewCell *templateCell = templateCellsByIdentifiers[identifier];
if (!templateCell) {
//如果不存在,调用dequeueReusableCellWithIdentifier获取并存入字典中
templateCell = [self dequeueReusableCellWithIdentifier:identifier];
NSAssert(templateCell != nil, @"Cell must be registered to table view for identifier - %@", identifier);
templateCell.fd_isTemplateLayoutCell = YES;
templateCell.contentView.translatesAutoresizingMaskIntoConstraints = NO;
templateCellsByIdentifiers[identifier] = templateCell;
[self fd_debugLog:[NSString stringWithFormat:@"layout cell created - %@", identifier]];
}
return templateCell;
}
再来看下高度计算:
- (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell {
// 获取当前cell的宽度
CGFloat contentViewWidth = CGRectGetWidth(self.frame);
CGRect cellBounds = cell.bounds;
cellBounds.size.width = contentViewWidth;
cell.bounds = cellBounds;
CGFloat accessroyWidth = 0;
// If a cell has accessory view or system accessory type, its content view's width is smaller
// than cell's by some fixed values.
if (cell.accessoryView) {
accessroyWidth = 16 + CGRectGetWidth(cell.accessoryView.frame);
} else {
static const CGFloat systemAccessoryWidths[] = {
[UITableViewCellAccessoryNone] = 0,
[UITableViewCellAccessoryDisclosureIndicator] = 34,
[UITableViewCellAccessoryDetailDisclosureButton] = 68,
[UITableViewCellAccessoryCheckmark] = 40,
[UITableViewCellAccessoryDetailButton] = 48
};
accessroyWidth = systemAccessoryWidths[cell.accessoryType];
}
//根据accessoryType判断类型,并将宽度减去accessoryView的宽度
contentViewWidth -= accessroyWidth;
// If not using auto layout, you have to override "-sizeThatFits:" to provide a fitting size by yourself.
// This is the same height calculation passes used in iOS8 self-sizing cell's implementation.
//
// 1. Try "- systemLayoutSizeFittingSize:" first. (skip this step if 'fd_enforceFrameLayout' set to YES.)
// 2. Warning once if step 1 still returns 0 when using AutoLayout
// 3. Try "- sizeThatFits:" if step 1 returns 0
// 4. Use a valid height or default row height (44) if not exist one
CGFloat fittingHeight = 0;
//判断是FrameLayout还是autoLayout
if (!cell.fd_enforceFrameLayout && contentViewWidth > 0) {
// autoLayout
// Add a hard width constraint to make dynamic content views (like labels) expand vertically instead
// of growing horizontally, in a flow-layout manner.
NSLayoutConstraint *widthFenceConstraint = [NSLayoutConstraint constraintWithItem:cell.contentView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:contentViewWidth];
// [bug fix] after iOS 10.3, Auto Layout engine will add an additional 0 width constraint onto cell's content view, to avoid that, we add constraints to content view's left, right, top and bottom.
static BOOL isSystemVersionEqualOrGreaterThen10_2 = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
isSystemVersionEqualOrGreaterThen10_2 = [UIDevice.currentDevice.systemVersion compare:@"10.2" options:NSNumericSearch] != NSOrderedAscending;
});
NSArray<NSLayoutConstraint *> *edgeConstraints;
if (isSystemVersionEqualOrGreaterThen10_2) {
// To avoid confilicts, make width constraint softer than required (1000)
widthFenceConstraint.priority = UILayoutPriorityRequired - 1;
// Build edge constraints
NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:cell.contentView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:cell attribute:NSLayoutAttributeLeft multiplier:1.0 constant:0];
NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:cell.contentView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:cell attribute:NSLayoutAttributeRight multiplier:1.0 constant:accessroyWidth];
NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:cell.contentView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:cell attribute:NSLayoutAttributeTop multiplier:1.0 constant:0];
NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:cell.contentView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:cell attribute:NSLayoutAttributeBottom multiplier:1.0 constant:0];
edgeConstraints = @[leftConstraint, rightConstraint, topConstraint, bottomConstraint];
[cell addConstraints:edgeConstraints];
}
//为contentView添加一个宽度的约束(在自动约束中, label的换行要依赖superView的宽度和约束进行计算, 从而得到高度, 一般做法是给cell填充数据, 然后计算高度, 缓存; 而cell宽度, 在填充数据的时候, 是有问题的, 因为storyboard中建立的cell的宽度是不确定的, 但是如果写死, 又无法适配宽屏手机, 这就导致最后算的cell的高度不准确, 导致一些奇怪的UI布局, 解决办法正如源码中所展示的那样, 先为cell添加好宽度约束, 然后再计算高度, 再移除宽度约束, 这样一来, 就解决了问题)
[cell.contentView addConstraint:widthFenceConstraint];
//通过systemLayoutSizeFittingSize:方法获取cell的高度
fittingHeight = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;
//移除宽度约束
[cell.contentView removeConstraint:widthFenceConstraint];
if (isSystemVersionEqualOrGreaterThen10_2) {
[cell removeConstraints:edgeConstraints];
}
[self fd_debugLog:[NSString stringWithFormat:@"calculate using system fitting size (AutoLayout) - %@", @(fittingHeight)]];
}
if (fittingHeight == 0) {
#if DEBUG
// Warn if using AutoLayout but get zero height.
if (cell.contentView.constraints.count > 0) {
if (!objc_getAssociatedObject(self, _cmd)) {
NSLog(@"[FDTemplateLayoutCell] Warning once only: Cannot get a proper cell height (now 0) from '- systemFittingSize:'(AutoLayout). You should check how constraints are built in cell, making it into 'self-sizing' cell.");
objc_setAssociatedObject(self, _cmd, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
#endif
// 如果是 frame layout. 使用 '- sizeThatFits:' 方法
// Note: fitting height should not include separator view.
fittingHeight = [cell sizeThatFits:CGSizeMake(contentViewWidth, 0)].height;
[self fd_debugLog:[NSString stringWithFormat:@"calculate using sizeThatFits - %@", @(fittingHeight)]];
}
// Still zero height after all above.
if (fittingHeight == 0) {
// Use default row height.
fittingHeight = 44;
}
// Add 1px extra space for separator line if needed, simulating default UITableViewCell.
//如果tableView的separatorStyle 不是UITableViewCellSeparatorStyleNone, 则加1px的高度, 这里是1px, 所以要除以scale
if (self.separatorStyle != UITableViewCellSeparatorStyleNone) {
fittingHeight += 1.0 / [UIScreen mainScreen].scale;
}
//返回高度
return fittingHeight;
}
另:如果是通过Key缓存,原理是几近类似的,区别在于存储的方式是改用了NSMutableDictionary去把height和key一一对应。
case FDSimulatedCacheModeCacheByKey: {
FDFeedEntity *entity = self.feedEntitySections[indexPath.section][indexPath.row];
return [tableView fd_heightForCellWithIdentifier:@"FDFeedCell" cacheByKey:entity.identifier configuration:^(FDFeedCell *cell) {
[self configureCell:cell atIndexPath:indexPath];
}];
};
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByKey:(id<NSCopying>)key configuration:(void (^)(id cell))configuration {
if (!identifier || !key) {
return 0;
}
// Hit cache
if ([self.fd_keyedHeightCache existsHeightForKey:key]) {
CGFloat cachedHeight = [self.fd_keyedHeightCache heightForKey:key];
[self fd_debugLog:[NSString stringWithFormat:@"hit cache by key[%@] - %@", key, @(cachedHeight)]];
return cachedHeight;
}
CGFloat height = [self fd_heightForCellWithIdentifier:identifier configuration:configuration];
[self.fd_keyedHeightCache cacheHeight:height byKey:key];
[self fd_debugLog:[NSString stringWithFormat:@"cached by key[%@] - %@", key, @(height)]];
return height;
}
对于UITableView+FDTemplateLayoutCellDebug,使用了objc_setAssociatedObject添加属性并加了一个NSLog方法方便打印数据:
- (BOOL)fd_debugLogEnabled {
return [objc_getAssociatedObject(self, _cmd) boolValue];
}
- (void)setFd_debugLogEnabled:(BOOL)debugLogEnabled {
objc_setAssociatedObject(self, @selector(fd_debugLogEnabled), @(debugLogEnabled), OBJC_ASSOCIATION_RETAIN);
}
- (void)fd_debugLog:(NSString *)message {
if (self.fd_debugLogEnabled) {
NSLog(@"** FDTemplateLayoutCell ** %@", message);
}
}
参考文献: