Eat,Code,Sleep

0%

FDTemplateLayoutCell源码学习

介绍

UITableView+FDTemplateLayoutCell 是一款用于自动计算并缓存 UITableViewCell 高度的框架。

能在需要动态计算 Cell 高度的场景中提升 UITableView 的流畅性度。

根据 UITableView-FDTemplateLayoutCell 的使用介绍我们知道,该框架侵入性极小,在 AutoLayout 约束可以 self-satisfied 满足高度计算的情况下, 仅仅只需要对 UITableViewDelegate- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath 方法做如下配置:

1
2
3
4
5
6
7
8
9
10
#import "UITableView+FDTemplateLayoutCell.h"

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
return [tableView fd_heightForCellWithIdentifier:@"reuse identifer" configuration:^(id cell) {
// Configure this cell with data, same as what you've done in "-tableView:cellForRowAtIndexPath:"
// Like:
// cell.entity = self.feedEntities[indexPath.row];
}];
}

源码解析

文件目录结构如下:

├── UITableView+FDIndexPathHeightCache.h
├── UITableView+FDIndexPathHeightCache.m
├── UITableView+FDKeyedHeightCache.h
├── UITableView+FDKeyedHeightCache.m
├── UITableView+FDTemplateLayoutCell.h
├── UITableView+FDTemplateLayoutCell.m
├── UITableView+FDTemplateLayoutCellDebug.h
└── UITableView+FDTemplateLayoutCellDebug.m

其中

UITableView+FDIndexPathHeightCacheUITableView+FDKeyedHeightCache 分别是 CacheByIndexPath CacheByKey 两种模式的缓存实现。

UITableView+FDTemplateLayoutCell 则暴露着返回高度的 API,缓存的计算和命中检测都在这个分类中。

UITableView+FDTemplateLayoutCellDebug 中则仅仅包装了 Debug 用到的 log 方法和 output 开关。

从最外层的 UITableView+FDTemplateLayoutCell分类 说起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@interface UITableView (FDTemplateLayoutCell)

- (__kindof UITableViewCell *)fd_templateCellForReuseIdentifier:(NSString *)identifier;

- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration;

- (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;
@end

@interface UITableViewCell (FDTemplateLayoutCell)

@property (nonatomic, assign) BOOL fd_isTemplateLayoutCell;

@property (nonatomic, assign) BOOL fd_enforceFrameLayout;

@end

一共为 UITableView 添加了四个方法,为 UITableViewCell 添加了两个 BOOL 属性。

第一个方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (__kindof UITableViewCell *)fd_templateCellForReuseIdentifier:(NSString *)identifier {
NSAssert(identifier.length > 0, @"Expect a valid identifier - %@", identifier);

NSMutableDictionary<NSString *, UITableViewCell *> *templateCellsByIdentifiers = objc_getAssociatedObject(self, _cmd);
if (!templateCellsByIdentifiers) {
templateCellsByIdentifiers = @{}.mutableCopy;
objc_setAssociatedObject(self, _cmd, templateCellsByIdentifiers, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

UITableViewCell *templateCell = templateCellsByIdentifiers[identifier];

if (!templateCell) {
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;
}

通过传入的 identifier 返回一个对应的 templateCell 用于计算高度

其中以 identifier 做 Key, templateCell做 Value 的可变数组通过关联对象保存在当前的 UITableView 对象上。

当数组中通过 identifier 取不到 templateCell 时,会使用 dequeueReusableCellWithIdentifier 创建一个,打上 fd_isTemplateLayoutCell 标记,并缓存入可变数组中。

这里把 identifiertemplateCell 对应关系存入数组,对查找速度的提升是应该是有限的,因为dequeueReusableCellWithIdentifier作为 Apple 提供的 UITableViewDelegate 的常用方法,开销是可以放心的。而是所以这么做,个人觉得主要是为了维护templateCellfd_isTemplateLayoutCell标记。这个标记的意义注释中提到:

1
2
3
4
5
6
7
8
9
10
/// Indicate this is a template layout cell for calculation only.
/// You may need this when there are non-UI side effects when configure a cell.
/// Like:
/// - (void)configureCell:(FooCell *)cell atIndexPath:(NSIndexPath *)indexPath {
/// cell.entity = [self entityAtIndexPath:indexPath];
/// if (!cell.fd_isTemplateLayoutCell) {
/// [self notifySomething]; // non-UI side effects
/// }
/// }
///

通过这个标记,你可以在配置 Cell 时,把过滤 templateCell 掉,不让它做那些不影响UI的操作以优化性能。比如一些不会影响到高度计算的时间表述换算~

方法- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration 会在配置好 configuration的 配置后,调用如下方法计算/返回高度:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
- (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell {
CGFloat contentViewWidth = CGRectGetWidth(self.frame);

CGRect cellBounds = cell.bounds;
cellBounds.size.width = contentViewWidth;
cell.bounds = cellBounds;

CGFloat rightSystemViewsWidth = 0.0;
for (UIView *view in self.subviews) {
if ([view isKindOfClass:NSClassFromString(@"UITableViewIndex")]) {
rightSystemViewsWidth = CGRectGetWidth(view.frame);
break;
}
}

// 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) {
rightSystemViewsWidth += 16 + CGRectGetWidth(cell.accessoryView.frame);
} else {
static const CGFloat systemAccessoryWidths[] = {
[UITableViewCellAccessoryNone] = 0,
[UITableViewCellAccessoryDisclosureIndicator] = 34,
[UITableViewCellAccessoryDetailDisclosureButton] = 68,
[UITableViewCellAccessoryCheckmark] = 40,
[UITableViewCellAccessoryDetailButton] = 48
};
rightSystemViewsWidth += systemAccessoryWidths[cell.accessoryType];
}

if ([UIScreen mainScreen].scale >= 3 && [UIScreen mainScreen].bounds.size.width >= 414) {
rightSystemViewsWidth += 4;
}

contentViewWidth -= rightSystemViewsWidth;


// 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;

if (!cell.fd_enforceFrameLayout && contentViewWidth > 0) {
// 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:-rightSystemViewsWidth];
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];
}

[cell.contentView addConstraint:widthFenceConstraint];

// Auto layout engine does its math
fittingHeight = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;

// Clean-ups
[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
// Try '- sizeThatFits:' for frame layout.
// 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.
if (self.separatorStyle != UITableViewCellSeparatorStyleNone) {
fittingHeight += 1.0 / [UIScreen mainScreen].scale;
}

return fittingHeight;
}

以上就是本框架计算高度的核心代码,过程其实并不复杂:

Parts of a table-view cell

  1. 用屏幕宽度减去 accessoryView 所需宽度,得到 Cell Content 宽度;

  2. 使用该宽度配置 AutoLayout 约束,然后调用 [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height 得到高度;

  3. 当 AutoLayout无法计算出高度或者 fd_enforceFrameLayout 标记为 YES 时,采用 [cell sizeThatFits:CGSizeMake(contentViewWidth, 0)].height 根据 Frame 计算;

  4. 还是无法计算出高度时采用默认高度 44.f ;
  5. 根据 separatorStyle 在必要时加上 1px 的分割线高度。

- (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 是我们使用框架中最常接触的两个方法,从方法名就可以看出,他们仅仅是缓存机制不一样。 cacheByIndexPath 和 cacheByKey 两种方式的对应实现分别放在 UITableView+FDIndexPathHeightCacheUITableView+FDKeyedHeightCache 两个分类中。

​ 两种方式的区别在于缓存时使用的 Key 不同,当使用 IndexPath 作为 Key 时,框架使用 Method Swizzling 对以下方法做了替换:

1
2
3
4
5
6
7
8
9
@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:)

这主要是为了实现 自动的缓存失效机制

无须担心你数据源的变化引起的缓存失效,当调用如-reloadData-deleteRowsAtIndexPaths:withRowAnimation:等任何一个触发 UITableView 刷新机制的方法时,已有的高度缓存将以最小的代价执行失效。如删除一个 indexPath 为 [0:5] 的 cell 时,[0:0] ~ [0:4] 的高度缓存不受影响,而 [0:5] 后面所有的缓存值都向前移动一个位置。自动缓存失效机制对 UITableView 的 9 个公有 API 都进行了分别的处理,以保证没有一次多余的高度计算。

当上拉加载等不需要清理 IndexPath的高度缓存时,框架提供了- (void)fd_reloadDataWithoutInvalidateIndexPathHeightCache方法。