Taste UITableView+FDTemplateLayoutCell(一)

UITableView+FDTemplateLayoutCell是一個優(yōu)化計算cell高度以追求性能的輕量級框架怒炸,雖然Apple在這方面也不斷做出改變以求達到優(yōu)化效果累驮,但似乎成效并不那么順利擎析,詳情可以閱讀該框架制作團隊的博文 優(yōu)化UITableViewCell高度計算的那些事幸海。

通過本文你可以閱讀到:

  • 從使用層面到深入代碼解析
  • swift 版本的初步實現(xiàn)

源碼淺析

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

UITableView+FDTemplateLayoutCell

可以看到司澎,框架只提供了4個類欺缘,可以說是十分輕量級的。但為了盡量簡化的去學(xué)習(xí)挤安,我們先除去用來打印debug信息的UITableView+FDTemplateLayoutCellDebug谚殊。同時,因為UITableView+FDKeyedHeightCacheUITableView+FDIndexPathHeightCache其實是兩套cell高度緩存機制漱受,那么我們可以二選一先進行學(xué)習(xí)络凿,瞄了一眼兩者的代碼量,你應(yīng)該也是果斷選擇了前者吧昂羡???

經(jīng)過一番篩選絮记,我們的探討重點縮小為:

  • UITableView+FDTemplateLayoutCell
  • UITableView+FDKeyedHeightCache

接下來,我們主要以框架的demo開始進行學(xué)習(xí)虐先。

如平常我們使用UITableView一樣怨愤,設(shè)置完reuseIdentifier和初始數(shù)據(jù)后,我們進行UITableView的Data SourceDelegate配置蛹批。

可以發(fā)現(xiàn)撰洗,該框架對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) {
            // 主要用來設(shè)置cell的樣式`accessoryType`和數(shù)據(jù)`entity`差导,即對cell進行配置。
            [self configureCell:cell atIndexPath:indexPath];
        }];

我們對一個框架的評價也包括其對項目源碼的入侵性猪勇,無入侵性則優(yōu)设褐。而該框架成功的在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無重用標(biāo)識符或者緩存key值為空犀被,則height值返回0;

    這比較容易理解外冀,reuseIdentifier為空去cell重用池當(dāng)然取不回對應(yīng)的cell锈玉。用值為空的key去fd_keyedHeightCache緩存池當(dāng)然也取不回對應(yīng)的高度值细办。fd_keyedHeightCache在步驟2介紹阅嘶。

  2. 命中緩存悬蔽,根據(jù)key值從key-height緩存池中取出對應(yīng)的height值。

    fd_keyedHeightCache:設(shè)置該關(guān)聯(lián)屬性的目的是創(chuàng)建key-height緩存池脑沿,其類型為FDKeyedHeightCache遭商,底層通過NSMutableDictionary<id<NSCopying>, NSNumber *>作為key-height關(guān)系進行一一對應(yīng)的存儲,并提供多種方法捅伤,后面再細說。

  3. 沒有命中緩存巫玻,先計算出height值丛忆,再將key-height對應(yīng)關(guān)系放入在key-height緩存池

  4. 返回計算完成并被緩存好的height值仍秤。

從上面的步驟中我們初步知道入侵性代碼大致都做了什么熄诡,但并沒有過多的深入了解,主要包括:一是FDKeyedHeightCache的數(shù)據(jù)結(jié)構(gòu)诗力,二是cell高度的計算實現(xiàn)凰浮。

這兩點恰恰是該框架的核心內(nèi)容。

緩存機制--FDKeyedHeightCache

FDKeyedHeightCache部分的代碼量非常少且容易理解苇本,這里主要提一下緩存失效問題袜茧。

FDKeyedHeightCache提供了兩種途徑,分別是使指定key的height失效方法:- (void)invalidateHeightForKey:(id<NSCopying>)key;和使整個key-height緩存池失效方法:- (void)invalidateAllHeightCache;瓣窄。

那么判定key-height失效的依據(jù)是什么笛厦?

我們可以從下面這段代碼中看出其tricky:

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

我們可以看到,判定失效的本質(zhì)依據(jù)是:height值為-1時俺夕,key-height失效裳凸,該判定同樣適用于FDIndexPathHeightCache緩存機制。

自動的緩存失效機制(本質(zhì)處理是將height值設(shè)為-1劝贸,或者清空高度緩存池)

無須擔(dān)心你數(shù)據(jù)源的變化引起的緩存失效姨谷,當(dāng)調(diào)用如-reloadData,-deleteRowsAtIndexPaths:withRowAnimation:等任何一個觸發(fā) UITableView 刷新機制的方法時映九,已有的高度緩存將以最小的代價執(zhí)行失效梦湘。如刪除一個 indexPath 為 [0:5] 的 cell 時,[0:0] ~ [0:4] 的高度緩存不受影響,而 [0:5] 后面所有的緩存值都向前移動一個位置践叠。自動緩存失效機制對 UITableView 的 9 個公有 API 都進行了分別的處理言缤,以保證沒有一次多余的高度計算。

cell高度計算

cell高度計算可以說是該框架中最復(fù)雜的部分禁灼,我們需要先對template layout cell的理解有個大致概念:可以把template layout cell看成是一個占位的cell管挟。

我們繼續(xù)點進去相關(guān)的代碼:

- (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. 無重用標(biāo)識符則height值返回0;

  2. 根據(jù)重用標(biāo)識符獲取templateLayoutCell弄捕;

  3. cell在從dequeueReusableCellWithIdentifier:取出之后,如果需要做一些額外的計算僻孝,比如說計算cell高度,手動調(diào)用prepareForReuse以確保與實際cell(顯示屏幕上)的行為一致守谓;

  4. 主要是在外部調(diào)用的block里為templateLayoutCell提供數(shù)據(jù)穿铆,以及對其進行一些自定義;

  5. 通過templateLayoutCell真正計算height值斋荞。

我們再對步驟2和5進行深入的解析荞雏,而這兩點恰恰是高度計算的核心:

根據(jù)重用標(biāo)識符獲取templateLayoutCell

點進去方法實現(xiàn):

- (__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;
}

繼續(xù)一步步探討:

  1. identifier斷言,這好理解平酿;

  2. 獲取identifier-templateCell緩存池templateCellsByIdentifiers凤优;

    templateCellsByIdentifiers的類型為NSMutableDictionary<NSString *, UITableViewCell *>

  3. 如果緩存池templateCellsByIdentifiers不存在,則創(chuàng)建一個蜈彼,并設(shè)置成關(guān)聯(lián)屬性筑辨;

  4. 根據(jù)標(biāo)識符identifier在identifier-templateCell緩存池中取出templateCell,找不到則返回nil幸逆;

  5. 在templateCell緩存池找不到對應(yīng)的templateCell的話棍辕,會先去系統(tǒng)的cell復(fù)用池中查找,如果沒有注冊對應(yīng)的identifier还绘,會被斷言楚昭,找到后則賦值給templateCell,被標(biāo)記為fd_isTemplateLayoutCell拍顷,且其內(nèi)容布局會變成frame layout哪替,最后該templateCell會被放入identifier-templateCell緩存池中。

被標(biāo)記為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相關(guān)的改動。

通過templateLayoutCell真正計算height值

跳進其實現(xiàn)方法爱沟,長達100多行的代碼著實顯示出其分量帅霜,但過程并不復(fù)雜,我們來看看:

- (CGFloat)fd_systemFittingHeightForConfiguratedCell:(UITableViewCell *)cell {
    // 1. 拿到tableView的寬度
    CGFloat contentViewWidth = CGRectGetWidth(self.frame);

    // 2. 將cell的寬度設(shè)置成跟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. 應(yīng)該是判斷設(shè)備是否是i6plus
    if ([UIScreen mainScreen].scale >= 3 && [UIScreen mainScreen].bounds.size.width >= 414) {
        rightSystemViewsWidth += 4;
    }

    // 6. cell實際contentView寬度大小
    contentViewWidth -= rightSystemViewsWidth;

    // 7. 下面已經(jīng)給出了接下來計算流程的注釋身冀,這里就不再過多解釋

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

關(guān)于tableviewCell的布局內(nèi)容可以閱讀一下Apple的這篇文檔:A Closer Look at Table View Cells

swift版本初步實現(xiàn)

到此钝尸,我們可以開始動手嘗試編寫該框架的一個初步實現(xiàn)的swift版本,其具有key-height緩存機制搂根,暫無indexPath-height緩存機制和高度失效機制珍促。

GitHub地址:TemplateLayoutCell

PS: 此項目只是作為學(xué)習(xí)該框架的一個playground~

歡迎大家指點,能點個??就更棒啦~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末剩愧,一起剝皮案震驚了整個濱河市猪叙,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌仁卷,老刑警劉巖穴翩,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異锦积,居然都是意外死亡芒帕,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進店門丰介,熙熙樓的掌柜王于貴愁眉苦臉地迎上來背蟆,“玉大人,你說我怎么就攤上這事哮幢∠ⅲ” “怎么了?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵家浇,是天一觀的道長。 經(jīng)常有香客問我碴裙,道長钢悲,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任舔株,我火速辦了婚禮莺琳,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘载慈。我一直安慰自己惭等,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布办铡。 她就那樣靜靜地躺著辞做,像睡著了一般。 火紅的嫁衣襯著肌膚如雪寡具。 梳的紋絲不亂的頭發(fā)上秤茅,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天,我揣著相機與錄音童叠,去河邊找鬼框喳。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的五垮。 我是一名探鬼主播乍惊,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼放仗!你這毒婦竟也來了润绎?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤匙监,失蹤者是張志新(化名)和其女友劉穎凡橱,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體亭姥,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡稼钩,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了达罗。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片坝撑。...
    茶點故事閱讀 38,094評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖粮揉,靈堂內(nèi)的尸體忽然破棺而出巡李,到底是詐尸還是另有隱情,我是刑警寧澤扶认,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布侨拦,位于F島的核電站,受9級特大地震影響辐宾,放射性物質(zhì)發(fā)生泄漏狱从。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一叠纹、第九天 我趴在偏房一處隱蔽的房頂上張望季研。 院中可真熱鬧,春花似錦誉察、人聲如沸与涡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽驼卖。三九已至,卻和暖如春鸿秆,著一層夾襖步出監(jiān)牢的瞬間款慨,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工谬莹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留檩奠,地道東北人桩了。 一個月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像埠戳,于是被迫代替她去往敵國和親井誉。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,828評論 2 345

推薦閱讀更多精彩內(nèi)容