UITableView控件可能是iOS中大家最常用的控件了(滾動視圖、cell重用、卡頓優化),今天要討論的不是這些高大上的話題,今天的話題只是cell高度的計算。
* 傳統frame布局下UITableViewCell 高度計算
* AutoLayout下UITableViewCell高度計算(iOS6、7)
* UITableViewCell高度計算之iOS8抽風之旅
* UITableViewCell高度計算之大一統
*第三方庫UITableView-FDTemplateLayoutCell源碼拋析
以下demo都是在cell高度可變的基礎上進行的
1、史上最傳統的UITableViewCell使用方法(號稱又笨又老),相信大家都用過這種,純frame布局,cell定制,手動傳入數據通過手動計算每一行cell的高度,代碼都不好意思上了。
還是上下之前的demo吧!
主要是在UITableViewCell(subCell)中使用一個靜態方法傳入數據并手動計算內容的高度
說到手動計算內容的高度,其實在cell里面大多是計算一些UILabel具體的寬高,根據內容計算UILabel對應的寬高,看下具體的API:
@interface NSString(UIStringDrawing)// Single line, no wrapping. Truncation based on the NSLineBreakMode.- (CGSize)sizeWithFont:(UIFont*)fontNS_DEPRECATED_IOS(2_0,7_0,"Use -sizeWithAttributes:");- (CGSize)sizeWithFont:(UIFont*)font forWidth:(CGFloat)width lineBreakMode:(NSLineBreakMode)lineBreakModeNS_DEPRECATED_IOS(2_0,7_0,"Use -boundingRectWithSize:options:attributes:context:");
這個地方Apple提供了一個NSString的分類,我們可以通過傳入對應的string 計算出label的自適應寬高,說到底就是使用sizeWithFont:系列重載函數
根據字符串計算label的content大小。
代碼中使用:
(NSString一個傳統的方法sizeWithFont:)來計算label新的frame,然后更新布局,之后返回一個預計算的高度值
+ (CGFloat)calulateHeightWithtTitle:(NSString*)title{CGFloatheight =20;CGSizelabelSize = [titlesizeWithFont:[UIFont systemFontOfSize:17] constrainedToSize:CGSizeMake(300,500)];height = height + labelSize.height;returnheight;}
最終方法的調用在:
- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath{ return [HomeCell calulateHeightWithtTitle:self.dataArray[indexPath.row]];}
來完成,并且return該float值作為cell的高度。
1、下面介紹第二種方法,使用自動布局下的cell高度計算,總體來講,自動布局下 的cell高度計算歸功于UILabel的布局,自動布局下默認無需要再指定view的frame,設置完對應的約束,label會自動根據內容的多少來完成布局。廢話少說先上體驗版demo。
上面描述到,傳統frame布局時間,主要是通過一些列手手動計算cell中label的寬高,然后在針對cell中的subView進行重新布局,最后得出一個整體的高度作為cell真實的高度,那么在自動布局中又該如何實現呢?首先自動布局一改了之前frame的概念,自動布局中不存在所謂的坐標 寬高,只有對應的約束。針對UILabel來說,自動布局下label會根據內容的多少自適應的調整label的大小,顯示對應的內容。這一點先看下UILabel在iOS6以后發生的變化:
// Support for constraint-based layout (auto layout)// If nonzero, this is used when determining -intrinsicContentSize for multiline labels@property(nonatomic)CGFloat preferredMaxLayoutWidthNS_AVAILABLE_IOS(6_0);
看到官方的注視,基本也大概有差不多的意思了,這東西實在autolayout下使用的,大概意思是給多行label設置一個布局時間優先使用的一個寬度。
在看下UIView的變化
@interfaceUIView (UIConstraintBasedLayoutFittingSize)/* The size fitting most closely to targetSize in which the receiver's subtree can be laid out while optimally satisfying the constraints. If you want the smallest possible size, pass UILayoutFittingCompressedSize; for the largest possible size, pass UILayoutFittingExpandedSize.Also see the comment for UILayoutPriorityFittingSizeLevel.*/- (CGSize)systemLayoutSizeFittingSize:(CGSize)targetSizeNS_AVAILABLE_IOS(6_0);// Equivalent to sending -systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority: with UILayoutPriorityFittingSizeLevel for both priorities.
意思大概就是說 當前的這個這個尺寸關系能夠最佳的適應接收器的子樹在滿足自適應約束的同時,如果想要一個最下的尺寸就設置為:UILayoutFittingCompressedSize;反之設置:UILayoutFittingExpandedSize。
實戰應用:
自動布局下的自適應cell高度玩轉,本教程完全依賴storybord ,依舊在代碼UI領域的媛猿們,需要轉變一下思維了。
(1)、創建故事板、初始化好tableview、cell的輸出口等,準備cell的約束,如圖:
cell上只有一個label,label的約束如下,大體就是具體上下左右各加上一個約束,將來在label中放在對應的內容文字,自適應高度(不要忘了設置cell的identifier)。
(2)、部分實現處理代碼
ViewController中部分代理方法
- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView{return1;}- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section{ return [self.dataArraycount];}- (UITableViewCell*)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath{static NSString *cellIdentifier = @"HomeCell";HomeCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];cell.content.text= [self.dataArray objectAtIndex:indexPath.row];CGFloat preMaxWaith =[UIScreen mainScreen].bounds.size.width-108;[cell.contentset PreferredMaxLayoutWidth:preMaxWaith];[cell.contentlayout IfNeeded];returncell;}-(CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath{staticHomeCell*cell =nil;static dispatch_once_ tonceToken;//只會走一次dispatch_once(&onceToken, ^{cell = (HomeCell*)[tableView dequeueReusableCellWithIdentifier:@"HomeCell"];});//calculateCGFloatheight = [cell calulateHeightWithtTitle:[self.dataArray objectAtIndex:indexPath.row]desrip:[self.dataArray objectAtIndex:indexPath.row]];returnheight;}HomeCell.m -(CGFloat)calulateHeightWithtTitle:(NSString*)title desrip:(NSString*)descrip{//這里非常重要CGFloat preMaxWaith =[UIScreen mainScreen].bounds.size.width-108;[self.contentset PreferredMaxLayoutWidth:preMaxWaith];//[self.titleLabel setText:title];//這也很重要[self.content layoutIfNeeded];[self.content setText:descrip];[self.contentView layoutIfNeeded];CGSizesize = [self.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];//加1是關鍵returnsize.height+1.0f;}
自動布局版cell高度計算OK!!
1、說到iOS8,在iOS8下如果要計算cell的高度,代碼越來越少,工作越來越輕松,殊不知表面看起來特別人性的iOS8背地里面也有太多坑的勾當(具體原因見后面解釋)。
先上iOS的計算cell高度的體驗demo:
iOS8下計算cell高度的工作比起之前的版本更加輕松
(1)、故事版拖好對應的VC、cell,接下來上約束,約束如下:
整體來說與2中的約束差不多,分別設置label距離四周的約束情況。(本篇文章要實現的本來就是相同的效果,在不同版本下的的實現方式以及優劣的對比與優化。)
設置好約束后
(2). iOS8的cell高度計算代碼
設置tableview的屬性
self.tableView.estimatedRowHeight=44.0;self.tableView.rowHeight=UITableViewAutomaticDimension;
至此,iOS8cell高度自適應計算OK!! 就是這么簡單...
在介紹本欄目之前先上一張表:
heightForRowAtIndexPath:cell高度計算次數統計:
由于iOS7之后,tableview 提供了estimatedHeightForRowAtIndexPathCount的API,這就對cell高度計算的方法調用次數產生了影響。
下面首先說下estimatedHeightForRowAtIndexPathCount :
// Use the estimatedHeight methods to quickly calcuate guessed values which will allow for fast load times of the table.// If these methods are implemented, the above -tableView:heightForXXX calls will be deferred until views are ready to be displayed, so more expensive logic can be placed there.- (CGFloat)tableView:(UITableView*)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath*)indexPathNS_AVAILABLE_IOS(7_0);
apple的文檔里面說大概意思是estimatedHeight可以快速的預估一個cell的高度值,從而讓table的加載速度更快。整體來說就是tableview在渲染的時間,他會首先根據內容計算每個cell的高度,從而計算出tableview的的一個contentsize(tableview繼承于scrollview),但是如果有一萬行數據,那么這個計算的過程會非常的卡頓,從而影響table的load的速度,我們可以給cell(除了當前需要顯示在屏幕上的cell)設置一個預估的高度值,這樣就大大節省了計算高度的損耗與開銷。
由上圖可以看出,iOS7 的tableview對于cell的高度是有緩存功能的,當劃到底部,從底部再往頂部滑動時間,heightForRowAtIndexPath:cell的調用次數為0,這說話cell的高度已經換存在了內存。iOS8、iOS9坑爹的一面在于當關閉高度預估方法時間(estimatedHeightForRowAtIndexPathCount),heightForRowAtIndexPath:cell的調用次數非常多(我們一般會在這個地方計算根據內容手動計算cell的高度,或者更新cell內不各種view的約束),這個過程如果頻繁的調用是非常耗損性能的,更悲劇的事造成tableview的卡頓,這個是最容忍不了的。當開啟高度預估時間,高度預估之時返回了一個定值,此時heightForRowAtIndexPath:cell的調用次數大大減少,高度計算的工作也就大大減少,此時就是我們想要的效果。
此外,這個地方有一個可能忽略的問題,當我們的工程從原來的iOS7遷移到8再到9的時間,如果這個地方不做進一步的優化,之前的代碼在新的系統下跑起來結果就不想而之了,為了能夠兼容到所有系統下的cell高度計算,推薦一個新的工具
UITableView-FDTemplateLayoutCell
參考博客:具體教程如
(1)、FDTemplateLayoutCell 使用教程
1. 下載FDTemplateLayoutCell第三方庫,導入工程
2. 導入頭文件
3. 使用FD實現heightForRowAtIndexPath方法,如下:
4、大功告成,使用fd一次性解決的iOS 6、7、8、9中的cell高度計算問題,FD采用自帶的緩存的機制,無需多次調用heightForRowAtIndexPath時間的cell高度計算開銷。
1、FDTemplateLayoutCell之所以能夠做到兼容到所有的系統版本下的tableview,主要在于它維護了一套自己的cell高度緩存,同時有效的利用了tableview的高度預估的功能。從新定義新的cell高度緩存策略,這一點解決了只有iOS7下系統才會主動緩存cell高度的這一難點,有了FDiOS8、9下也能使用緩存高度
2、開啟UITableView高度預估功能,優化heightForRowAtIndexPath的調用累計次數
(tableView:estimatedHeightForRowAtIndexPath: NS_AVAILABLE_IOS(7_0);)
由上可以看出estimatedHeightForRowAtIndexPath是iOS7才有的,iOS6是沒有這個代理的,這個時間不僅要問,難道要iOS必須支持iOS7+以上才能使用,答案當然不是,系統的API早已做了優化,estimatedHeightForRowAtIndexPath在iOS6下面默認是可以被忽略的,不會因為版本的問題引起異常。在iOS6下高度計算的策略會跟iOS8、9下有點類似,使用FD自己提供的緩存,也能達到有效的減少計算cell高度帶來的開銷。
談到FD,首先熟悉下之前的一個知識點, iOS知識點整理-RunLoop。可能有些老生常談了,也有可能部分童鞋看到直接暈掉了,其實大多iOS里面大多第三方庫的手段無外乎就是runtime(這個東西在java中叫reflact,java里面有AOP , iOS 其實跟這個差不多)、CF這些黑魔法之類的東西來進行偷天換日、移花接木。
小結:iOS 中的runloop
1、NSRunLoop提供了面向對象的API,但這些API不是線程安全的
2、CFRunLoopRef提供了純C函數的API,所有這些API都是線程安全的
NSRunLoop是cocoa提供的,這個東西可能大多人還是經常使用的,cell里面更新異步下載成功的圖片、啟用一個timer追加到當前的應用循環中、啟用一個常駐線程等;
CFRunLoopRef可能就相對陌生些,CF開頭跟定就是CoreFoundation中定義的,可以暫時理解為每個線程都有一個對應的runloop, 在一個runloop中可以有多種Model(模式),每個Mode又包含若干個source/Timer/Observe .
程序執行的時間當前runloop 只能存在一種Model,如果發生場景切換需要退出當前Model,進入下一個Model
系統一共提供了五種model:
NSDefaultRunLoopMode: App默認Mode,當沒有接收到ScrollView滾動是,主線程通常使用這個Mode NSTrackingRunLoopMode: 到接收到ScroolView或其子類的時候,主線程就會切換到這個模式下運行。 UIInitializationRunLoopMode:當App啟動時使用的第一個Mode,當啟動完成后不再使用。 NSRunLoopCommonModes,是一個tag,本質上不是一個Mode,默認
NSDefaultRunLoopMode和NSTrackingRunLoopMode都綁定這個tag。(應用場景:有時候我們需要添加一個NSTimer在RunLoop,在這時需要制定一個Modes,現在的 需求是:我們既要在默認模式下要監聽,在滾動模式下也要監聽,但只能制定一個模式,這是可以制定這個CommonMode) GSEventReceiveRunLoopMode:接受系統內部的Mode,通常做不到。處理不同事件使用不同的Mode,可以最大限度的把性能的最大化處理不同分類的事件,提高性能。
知道了這些,我們可以在此處做文章,我們發現UITableView(繼承UIScrollView)不滾動時間是NSDefaultRunLoopMode 模式,滾動時間是NSTrackingRunLoopMode模式,我們可以 通過注冊觀察者來實現讓tableview不滾動的時間再去計算所有的cell的高度,一旦當tableview開始滾動我們再去取得時間著時間緩存池里面已經計算 的差不多了,也就是說盡最大可能讓tableview不滾動的時間處理好所有的cell高度,緩存下來,等到滑動tableview的時間優先從緩存取,這個地方盡最大避免了邊滑動邊計算cell高度卡頓問題。
完成了這個知識點,接下來就是處理好緩存邏輯的事情了。
1、首先對于FD來說,維護cell的高度需要將計算過的cell的高度放進一個二維數組里面(section row)
FD中存在一個可維護的NSMutableArray sections; 可以先理解為一個嵌套起來的數組是一個二位的數組,接下來的時間會把tableview 某個section下的row對應的行對應的高度存在這個位置,
2、tableView渲染的時間,統一還是會走 heightForRowAtIndexPath方法的,我們只需要在此處直接獲取到cache里面的已經存儲的高度就行了,在此處避開cell的高度邏輯計算過程就到達了我們的目的。
FD組件已經作了很好的封裝,在heightForRowAtIndexPath中調用fd計算高度的方法,
- (CGFloat)fd_heightForCellWithIdentifier:(NSString*)identifier cacheByIndexPath:(NSIndexPath*)indexPath configuration:(void(^)(id))configuration{if(!identifier || !indexPath) {return0;}// Enable auto cache invalidation if you use this "cacheByIndexPath" API.if(!self.fd_autoCacheInvalidationEnabled) {self.fd_autoCacheInvalidationEnabled=YES;}// Enable precache if you use this "cacheByIndexPath" API.if(!self.fd_precacheEnabled) {self.fd_precacheEnabled=YES;// Manually trigger precache only for the first time.[selffd_precacheIfNeeded];}// Hit the cacheif([self.fd_cellHeightCachehasCachedHeightAtIndexPath:indexPath]) {[selffd_debugLog:[NSStringstringWithFormat:@"hit cache - [%@:%@] %@",@(indexPath.section),@(indexPath.row),@([self.fd_cellHeightCachecachedHeightAtIndexPath:indexPath])]];return[self.fd_cellHeightCachecachedHeightAtIndexPath:indexPath];}// Call basic height calculation method.CGFloatheight = [selffd_heightForCellWithIdentifier:identifierconfiguration:configuration];[selffd_debugLog:[NSStringstringWithFormat:@"cached - [%@:%@] %@",@(indexPath.section),@(indexPath.row),@(height)]];// Cache it[self.fd_cellHeightCachecacheHeight:heightbyIndexPath:indexPath];returnheight;}
這個步驟中,基本可以看出FD的使用策略,首先開啟一個[selffd_precacheIfNeeded]的操作(這個過程是做了一個預計算cell高度的操作,稍后詳解),接下來的過程就是從緩存池中根據IndexPath(cell高度預存儲在一個模擬的二維數組中)去讀取cell的高度,如果cache命中就直接返回cell高度,否則執行:
// Call basic height calculation method.CGFloatheight = [selffd_heightForCellWithIdentifier:identifierconfiguration:configuration];
去手動計算一次cell的高度,計算獲得后存入緩存池
// Cache it[self.fd_cellHeightCachecacheHeight:heightbyIndexPath:indexPath];
最后返回高度。
3、介紹FD的緩存池
FD在這個地方利用了runloop的黑魔法,通過注冊一個觀察者,當tableview停止滑動的他會主動去計算當前數據源中的剩余的cell的高度,計算完以后存儲在緩存池中,這個調用就是(2)中的
// Enable precache if you use this "cacheByIndexPath" API.if(!self.fd_precacheEnabled) {self.fd_precacheEnabled=YES;// Manually trigger precache only for the first time.[selffd_precacheIfNeeded];}
在這個開啟調用中,通過一些列手段將tableview不滾動時間去計算cell的高度(具體原理此處省略),計算后存入緩存池sections,sections是一個可變數組,筆者顯示把這個理解成一個內存存儲元素是可變數組的數組(模擬一個二維數組),FD先是給自己增加了一個屬性sections作為緩存池,通過objc_setAssociatedObject給分類增加屬性的此處就不介紹了,
[selfbuildHeightCachesAtIndexPathsIfNeeded:@[indexPath]];self.sections[indexPath.section][indexPath.row] =@(height);// Build every section array or row array which is smaller than given index path.[indexPaths enumerateObjectsUsingBlock:^(NSIndexPath*indexPath,NSUIntegeridx,BOOL*stop) {NSAssert(indexPath.section>=0,@"Expect a positive section rather than '%@'.",@(indexPath.section));for(NSIntegersection =0; section <= indexPath.section; ++section) {if(section >=self.sections.count) {self.sections[section] =@[].mutableCopy;}}NSMutableArray*rows =self.sections[indexPath.section];for(NSIntegerrow =0; row <= indexPath.row; ++row) {if(row >= rows.count) {rows[row] =@(_FDTemplateLayoutCellHeightCacheAbsentValue);}}}];
此處主要是構造一個緩存池,通過在sections中存儲一個NSMutableArray,模擬一個二維的數組
通過indexPath的section 和 row作為下標,構造完成直接將高度存進去:
self.sections[indexPath.section][indexPath.row] =@(height);
至此緩存池結束
4、至此FD的核心手段大題已經講完,接下來就是考慮到tableview的增刪改插的時間的處理問題,這一系列的動作都會對緩存池的更新有一定的影響,FD已經做了最大的限度的優化,依舊runtime, swizzling的魔法就不多解釋了。
dispatch_once(&onceToken, ^{SELselectors[] = {@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:)};for(NSUIntegerindex =0; indexSELoriginalSelector = selectors[index];SELswizzledSelector =NSSelectorFromString([@"fd_"stringByAppendingString:NSStringFromSelector(originalSelector)]);MethodoriginalMethod =class_getInstanceMethod(self, originalSelector);MethodswizzledMethod =class_getInstanceMethod(self, swizzledSelector);method_exchangeImplementations(originalMethod, swizzledMethod);}});
小結:
FD是一個封裝的很完美的庫,其實從一開始使用這個庫就喜歡上了,作者是百度的sunnyxy,另一方面iOS中runtime仍舊是一個很強大的東西,大多的第三方庫無非都是基本objc runtime做的一些便捷優化,但是一個優秀的第三方庫需要作者不斷的完善和大家的共同努力。
新聞熱點
疑難解答