美文网首页
Taste UITableView+FDTemplateLayo

Taste UITableView+FDTemplateLayo

作者: JABread | 来源:发表于2018-10-01 10:16 被阅读67次

    UITableView+FDTemplateLayoutCell是一个优化计算cell高度以追求性能的轻量级框架,虽然Apple在这方面也不断做出改变以求达到优化效果,但似乎成效并不那么顺利,详情可以阅读该框架制作团队的博文 优化UITableViewCell高度计算的那些事

    通过本文你可以阅读到:

    • 从使用层面到深入代码解析
    • swift 版本的初步实现

    源码浅析

    首先,我们先分析框架的组成,github地址:传送门

    UITableView+FDTemplateLayoutCell

    可以看到,框架只提供了4个类,可以说是十分轻量级的。但为了尽量简化的去学习,我们先除去用来打印debug信息的UITableView+FDTemplateLayoutCellDebug。同时,因为UITableView+FDKeyedHeightCacheUITableView+FDIndexPathHeightCache其实是两套cell高度缓存机制,那么我们可以二选一先进行学习,瞄了一眼两者的代码量,你应该也是果断选择了前者吧?😆

    经过一番筛选,我们的探讨重点缩小为:

    • UITableView+FDTemplateLayoutCell
    • UITableView+FDKeyedHeightCache

    接下来,我们主要以框架的demo开始进行学习。

    如平常我们使用UITableView一样,设置完reuseIdentifier和初始数据后,我们进行UITableView的Data SourceDelegate配置。

    可以发现,该框架对Data Source部分无代码侵入性,但对Delegate- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;部分存在代码侵入性。

    我们主要观察FDSimulatedCacheModeCacheByKey这个case:

    FDFeedEntity *entity = self.feedEntitySections[indexPath.section][indexPath.row];
          return [tableView fd_heightForCellWithIdentifier:@"FDFeedCell"
                                                cacheByKey:entity.identifier 
                                             configuration:^(FDFeedCell *cell) {
                // 主要用来设置cell的样式`accessoryType`和数据`entity`,即对cell进行配置。
                [self configureCell:cell atIndexPath:indexPath];
            }];
    

    我们对一个框架的评价也包括其对项目源码的入侵性,无入侵性则优。而该框架成功的在Data Source部分做到无入侵性,但为何不得不在返回cell高度这个Delegate中做这种具入侵性的行为?我们点进去看看。

    - (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)]];
        
        // 4
        return height;
    }
    

    一步步来探讨:

    1. cell无重用标识符或者缓存key值为空,则height值返回0;

      这比较容易理解,reuseIdentifier为空去cell重用池当然取不回对应的cell。用值为空的key去fd_keyedHeightCache缓存池当然也取不回对应的高度值。fd_keyedHeightCache在步骤2介绍。

    2. 命中缓存,根据key值从key-height缓存池中取出对应的height值。

      fd_keyedHeightCache:设置该关联属性的目的是创建key-height缓存池,其类型为FDKeyedHeightCache,底层通过NSMutableDictionary<id<NSCopying>, NSNumber *>作为key-height关系进行一一对应的存储,并提供多种方法,后面再细说。

    3. 没有命中缓存,先计算出height值,再将key-height对应关系放入在key-height缓存池

    4. 返回计算完成并被缓存好的height值。

    从上面的步骤中我们初步知道入侵性代码大致都做了什么,但并没有过多的深入了解,主要包括:一是FDKeyedHeightCache的数据结构,二是cell高度的计算实现。

    这两点恰恰是该框架的核心内容。

    缓存机制--FDKeyedHeightCache

    FDKeyedHeightCache部分的代码量非常少且容易理解,这里主要提一下缓存失效问题。

    FDKeyedHeightCache提供了两种途径,分别是使指定key的height失效方法:- (void)invalidateHeightForKey:(id<NSCopying>)key;和使整个key-height缓存池失效方法:- (void)invalidateAllHeightCache;

    那么判定key-height失效的依据是什么?

    我们可以从下面这段代码中看出其tricky:

    - (BOOL)existsHeightForKey:(id<NSCopying>)key {
        NSNumber *number = self.mutableHeightsByKeyForCurrentOrientation[key];
        return number && ![number isEqualToNumber:@-1];
    }
    

    我们可以看到,判定失效的本质依据是:height值为-1时,key-height失效,该判定同样适用于FDIndexPathHeightCache缓存机制。

    自动的缓存失效机制(本质处理是将height值设为-1,或者清空高度缓存池)

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

    cell高度计算

    cell高度计算可以说是该框架中最复杂的部分,我们需要先对template layout cell的理解有个大致概念:可以把template layout cell看成是一个占位的cell。

    我们继续点进去相关的代码:

    - (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);
        }
    
        // 5
        return [self fd_systemFittingHeightForConfiguratedCell:templateLayoutCell];
    }
    

    一步步来探讨:

    1. 无重用标识符则height值返回0;

    2. 根据重用标识符获取templateLayoutCell;

    3. cell在从dequeueReusableCellWithIdentifier:取出之后,如果需要做一些额外的计算,比如说计算cell高度,手动调用prepareForReuse以确保与实际cell(显示屏幕上)的行为一致;

    4. 主要是在外部调用的block里为templateLayoutCell提供数据,以及对其进行一些自定义;

    5. 通过templateLayoutCell真正计算height值。

    我们再对步骤2和5进行深入的解析,而这两点恰恰是高度计算的核心:

    根据重用标识符获取templateLayoutCell

    点进去方法实现:

    - (__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);
    
        // 3
        if (!templateCellsByIdentifiers) {
            templateCellsByIdentifiers = @{}.mutableCopy;
            objc_setAssociatedObject(self, _cmd, templateCellsByIdentifiers, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        }
    
        // 4
        UITableViewCell *templateCell = templateCellsByIdentifiers[identifier];
    
        // 5
        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]];
        }
    
        // 6
        return templateCell;
    }
    

    继续一步步探讨:

    1. identifier断言,这好理解;

    2. 获取identifier-templateCell缓存池templateCellsByIdentifiers

      templateCellsByIdentifiers的类型为NSMutableDictionary<NSString *, UITableViewCell *>

    3. 如果缓存池templateCellsByIdentifiers不存在,则创建一个,并设置成关联属性;

    4. 根据标识符identifier在identifier-templateCell缓存池中取出templateCell,找不到则返回nil;

    5. 在templateCell缓存池找不到对应的templateCell的话,会先去系统的cell复用池中查找,如果没有注册对应的identifier,会被断言,找到后则赋值给templateCell,被标记为fd_isTemplateLayoutCell,且其内容布局会变成frame layout,最后该templateCell会被放入identifier-templateCell缓存池中。

    被标记为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
    ///       }
    ///   }
    ///
    

    通过判断cell是否为templateCell,如果是则表示在配置cell时只进行布局计算,不去做UI相关的改动。

    通过templateLayoutCell真正计算height值

    跳进其实现方法,长达100多行的代码着实显示出其分量,但过程并不复杂,我们来看看:

    - (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell {
        // 1. 拿到tableView的宽度
        CGFloat contentViewWidth = CGRectGetWidth(self.frame);
    
        // 2. 将cell的宽度设置成跟tableView一样宽
        CGRect cellBounds = cell.bounds;
        cellBounds.size.width = contentViewWidth;
        cell.bounds = cellBounds;
    
        // 3. 拿到快速索引的宽度(如果有)
        CGFloat rightSystemViewsWidth = 0.0;
        for (UIView *view in self.subviews) {
            if ([view isKindOfClass:NSClassFromString(@"UITableViewIndex")]) {
                rightSystemViewsWidth = CGRectGetWidth(view.frame);
                break;
            }
        }
    
        // 4. 主要是计算Accessory view的宽度。
        // 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];
        }
    
        // 5. 应该是判断设备是否是i6plus
        if ([UIScreen mainScreen].scale >= 3 && [UIScreen mainScreen].bounds.size.width >= 414) {
            rightSystemViewsWidth += 4;
        }
    
        // 6. cell实际contentView宽度大小
        contentViewWidth -= rightSystemViewsWidth;
    
        // 7. 下面已经给出了接下来计算流程的注释,这里就不再过多解释
    
        // 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;
    }
    

    关于tableviewCell的布局内容可以阅读一下Apple的这篇文档:A Closer Look at Table View Cells

    swift版本初步实现

    到此,我们可以开始动手尝试编写该框架的一个初步实现的swift版本,其具有key-height缓存机制,暂无indexPath-height缓存机制和高度失效机制。

    GitHub地址:TemplateLayoutCell

    PS: 此项目只是作为学习该框架的一个playground~

    欢迎大家指点,能点个💖就更棒啦~

    相关文章

      网友评论

          本文标题:Taste UITableView+FDTemplateLayo

          本文链接:https://www.haomeiwen.com/subject/ghxnoftx.html