Taste UITableView+FDTemplateLayoutCell(一)

UITableView+FDTemplateLayoutCell是一個優化計算cell高度以追求性能的輕量級框架,雖然Apple在這方面也不斷作出改變以求達到優化效果,但彷佛成效並不那麼順利,詳情能夠閱讀該框架製做團隊的博文 優化UITableViewCell高度計算的那些事html

經過本文你能夠閱讀到:git

  • 從使用層面到深刻代碼解析
  • swift 版本的初步實現

源碼淺析

首先,咱們先分析框架的組成,github地址:傳送門github

UITableView+FDTemplateLayoutCell

能夠看到,框架只提供了4個類,能夠說是十分輕量級的。但爲了儘可能簡化的去學習,咱們先除去用來打印debug信息的UITableView+FDTemplateLayoutCellDebug。同時,由於UITableView+FDKeyedHeightCacheUITableView+FDIndexPathHeightCache實際上是兩套cell高度緩存機制,那麼咱們能夠二選一先進行學習,瞄了一眼二者的代碼量,你應該也是果斷選擇了前者吧?😆swift

通過一番篩選,咱們的探討重點縮小爲:緩存

  • UITableView+FDTemplateLayoutCell
  • UITableView+FDKeyedHeightCache

接下來,咱們主要以框架的demo開始進行學習。bash

如日常咱們使用UITableView同樣,設置完reuseIdentifier和初始數據後,咱們進行UITableView的Data SourceDelegate配置。數據結構

能夠發現,該框架對Data Source部分無代碼侵入性,但對Delegate- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;部分存在代碼侵入性。app

咱們主要觀察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中作這種具入侵性的行爲?咱們點進去看看。ide

- (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~

歡迎你們指點,能點個💖就更棒啦~

相關文章
相關標籤/搜索