UITableView+FDTemplateLayoutCell is a lightweight framework that optimizes computing cell height for performance. Apple has been making changes in this area, but it doesn’t seem to be working that well. For details, read the framework team’s blog post about optimizing UITableViewCell for high computing.
In this article you can read:
- From the usage level to deep code parsing
- Preliminary implementation of swift version
Source analyses
First, let’s analyze the structure of the framework, github address: portal
As you can see, the framework provides only four classes, which is quite lightweight. But as far as possible in order to simplify to learn, we take the UITableView + FDTemplateLayoutCellDebug used to print the debug information. Also, because UITableView+FDKeyedHeightCache and UITableView+FDIndexPathHeightCache are two sets of cell height cache mechanisms, we can choose one of them first. Surely you chose the former too? 😆
After some screening, the focus of our discussion is narrowed down to:
- UITableView+FDTemplateLayoutCell
- UITableView+FDKeyedHeightCache
Next, we will start with a demo of the framework.
After setting the reuseIdentifier and the initial Data, we configure the Data Source and Delegate of the UITableView as we normally do with UITableView.
It can be found that the framework is non-code intrusive to the Data Source part, But in Delegate – (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath; Part of the code is intrusive.
We mainly observe FDSimulatedCacheModeCacheByKey this case:
FDFeedEntity *entity = self.feedEntitySections[indexPath.section][indexPath.row];
return [tableView fd_heightForCellWithIdentifier:@"FDFeedCell"CacheByKey: entity identifier configuration: ^ (FDFeedCell * cell) {/ / is mainly used to set the style of the cell ` accessoryType ` and data ` entity `, namely on the cell configuration. [self configureCell:cell atIndexPath:indexPath]; }];Copy the code
Our evaluation of a framework also includes how intrusive it is to the project’s source code. The framework succeeds in being non-invasive in the Data Source section, but why does it have to be invasive in the return cell height Delegate? Let’s click inside.
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier cacheByKey:(id<NSCopying>)key configuration:(void (^)(id cell))configuration {
// 1
if(! identifier || ! key) {return 0;
}
// 2
// 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;
}
// 3
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)]]; / / 4return height;
}
Copy the code
Step by step:
-
If the cell has no reuse identifier or the cache key is empty, the height value is 0.
If the reuseIdentifier is empty, the pool will not fetch the corresponding cell. Using a null key in the fd_keyedHeightCache pool does not return the height. Fd_keyedHeightCache is described in Step 2.
-
Hits the cache and retrieves the corresponding height from the key-height cache pool based on the key value.
Fd_keyedHeightCache: The purpose of setting this association property is to create a key-height cache pool of type FDKeyedHeightCache, The underlying NSMutableDictionary< ID
, NSNumber *> as a key-height relationship for one-to-one storage, and provides multiple methods, more on this later.
-
If there is no cache hit, the height is calculated and the key-height mapping is added to the key-height cache pool.
-
Returns the computed and cached height.
From the above steps, we have a preliminary understanding of what the invasive code does, but we do not have too much in-depth understanding, mainly including: first, FDKeyedHeightCache data structure, and second, cell high computing implementation.
These two points are at the heart of the framework.
Cache mechanism -FDKeyedHeightCache
FDKeyedHeightCache part of the code is very small and easy to understand, here mainly to mention cache invalidation problem.
– (void)invalidateHeightForKey:(id
)key; Void invalidateAllHeightCache – (void)invalidateAllHeightCache; .
So what is the basis for determining key-height failure?
We can see it in this code tricky:
- (BOOL)existsHeightForKey:(id<NSCopying>)key {
NSNumber *number = self.mutableHeightsByKeyForCurrentOrientation[key];
returnnumber && ! [number isEqualToNumber:@-1]; }Copy the code
We can see that the essential basis for determining the failure is: key-height is invalid when the height value is -1. This judgment also applies to FDIndexPathHeightCache mechanism.
Automatic cache invalidation mechanism (essentially setting height to -1 or emptying the height cache pool)
Don’t need to worry about your cache invalidation caused the change of the data source, when calling such as – reloadData – deleteRowsAtIndexPaths: withRowAnimation: any triggered a UITableView refresh mechanism of methods, such as, The existing high cache will perform invalidation with minimal cost. For example, if a cell whose indexPath is [0:5] is deleted, the cache height from [0:0] to [0:4] is not affected, and all cache values after [0:5] are moved one position forward. The automatic cache invalidation mechanism handles each of the nine public apis of UITableView separately to ensure that there is no one extra height calculation.
Cell height calculation
The template Layout cell is one of the most complex parts of the framework. You can think of the Template Layout cell as a placeholder cell.
Let’s go ahead and click on the relevant code:
- (CGFloat)fd_heightForCellWithIdentifier:(NSString *)identifier configuration:(void (^)(id cell))configuration {
// 1
if(! identifier) {return 0;
}
// 2
UITableViewCell *templateLayoutCell = [self fd_templateCellForReuseIdentifier:identifier];
// 3
// Manually calls to ensure consistent behavior with actual cells. (that are displayed on screen)
[templateLayoutCell prepareForReuse];
// 4
// Customize and provide content for our template cell.
if(configuration) { configuration(templateLayoutCell); } / / 5return [self fd_systemFittingHeightForConfiguratedCell:templateLayoutCell];
}
Copy the code
Step by step:
-
If there is no reuse identifier, height returns 0;
-
Get templateLayoutCell based on reuse identifier;
-
Cell from dequeueReusableCellWithIdentifier: removed, if you need to do some additional calculation, such as cell height calculation, manual call prepareForReuse to ensure with the actual cell (display screen) behavior consistent;
-
Provide data to the templateLayoutCell in the block called externally and do some customization to it.
-
The templateLayoutCell actually calculates the height.
Let’s take a closer look at steps 2 and 5, which are at the heart of height computing:
Get templateLayoutCell based on reuse identifier
Click on the method to implement:
- (__kindof UITableViewCell *)fd_templateCellForReuseIdentifier:(NSString *)identifier {
// 1
NSAssert(identifier.length > 0, @"Expect a valid identifier - %@", identifier); // 2 NSMutableDictionary<NSString *, UITableViewCell *> *templateCellsByIdentifiers = objc_getAssociatedObject(self, _cmd); / / 3if(! templateCellsByIdentifiers) { templateCellsByIdentifiers = @{}.mutableCopy; objc_setAssociatedObject(self, _cmd, templateCellsByIdentifiers, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } // 4 UITableViewCell *templateCell = templateCellsByIdentifiers[identifier]; / / 5if(! 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]]; } / / 6return templateCell;
}
Copy the code
Continue the discussion step by step:
-
For identifier assertions, this is easy to understand;
-
To obtain the identifier – templateCellsByIdentifiers templateCell buffer pool;
TemplateCellsByIdentifiers type for NSMutableDictionary < nsstrings *, UITableViewCell * >
-
If the cache pool templateCellsByIdentifiers does not exist, then create a, and set the attribute;
-
Fetch templateCell from identifier-templateCell cache pool according to identifier, return nil if not found;
-
If the corresponding templateCell cannot be found in the templateCell cache pool, the system will first search for the corresponding cell reuse pool. If the corresponding identifier is not registered, it will be affirmed and then assigned to templateCell. Marked as fd_isTemplateLayoutCell, its content layout becomes a Frame Layout, and finally the templateCell is placed in the identifier-templateCell cache pool.
Fd_isTemplateLayoutCell is marked as fd_isTemplateLayoutCell.
/// 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 /// } /// } ///Copy the code
Check whether the cell is templateCell. If the cell is templateCell, only layout calculation is performed during cell configuration, and uI-related changes are not performed.
The templateLayoutCell actually calculates the height
Jumping into the implementation method, more than 100 lines of code really shows its weight, but the process is not complicated, let’s take a look:
- (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell { // 1. CGFloat contentViewWidth = CGRectGetWidth(self.frame); CGRect cellBounds = cell.bounds; // Set the cell width to the same width as the tableView. cellBounds.size.width = contentViewWidth; cell.bounds = cellBounds; // 3. Get the quick index width (if any) CGFloat rightSystemViewsWidth = 0.0;for (UIView *view in self.subviews) {
if ([view isKindOfClass:NSClassFromString(@"UITableViewIndex")]) {
rightSystemViewsWidth = CGRectGetWidth(view.frame);
break; }} // 4. Calculate the width of the Accessory view. // If a cell has accessory view or system accessorytype, 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]; } // 5. Check whether the device is i6Plusif([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
*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: constant: 1.0 - 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; }Copy the code
For A Closer Look at Table View Cells, see Apple’s document A Closer Look at Table View Cells
Preliminary implementation of swift version
At this point, we can start by trying to write a swift version of a preliminary implementation of the framework with key-height caching, but no indexpath-height caching or height invalidation.
GitHub address: TemplateLayoutCell
PS: This project is just a playground for learning the framework ~
Welcome everyone to give advice, can point 💖 is even better ~