大家基本上都做過這樣的需求:在UITableView上展示文字,且文字內容長短不一,每一行單元格都要動態計算高度,使得單元格可以剛好容納下需要展示的文字。為了方便講解,我們把文字框設定成一個距離cell上下左右均有20px間距的UILabel,需要單元格動態調整高度,使得文字框剛好可以展示出所有的文字內容。
實現方案
需求本身並不是非常複雜,實現這個需求基本上可以採用兩種方法:
1、程式碼動態計算高度
2、利用iOS8中UITableView的estimatedRowHeight新特性通過約束計算高度
我們先來看一下兩種方案的實現方式:
程式碼動態計算高度
在UITableViewCell的自定義類中增加一個計算cell高度的類方法,具體程式碼如下:
+ (CGFloat)calculateTitleWidth:(NSString *)title{
CGFloat stringWidth = 0;
CGSize size = CGSizeMake(kRBScreenWidth - 20.0f*2, MAXFLOAT);
if (title.length > 0) {
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 70000
stringWidth = [title
boundingRectWithSize:size
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName:kRBTextFont}
context:nil].size.height;
#else
//iOS7.0以下方法
stringWidth = [title sizeWithFont:kRBTextFont
constrainedToSize:size
lineBreakMode:NSLineBreakByCharWrapping].height;
#endif
}
return stringWidth;
}
複製程式碼
當我們通過- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
方法得到對應的cell之後,呼叫cell的- (void)buildData:(NSString *)title
方法,填充文字,設定文字框高度:
- (void)buildData:(NSString *)title{
self.titleLabel.text = title;
self.titleLabel.frame = CGRectMake(20.0f, 20.0f, kRBScreenWidth - 20.0f*2, [RBAutoSizeTableViewCell calculateTitleWidth:title]);
}
複製程式碼
重寫UITableViewDataSource的protocol方法,動態計算每一行的高度:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
return [RBAutoSizeTableViewCell calculateTitleWidth:self.titles[indexPath.row]] + 20.0*2;
}
複製程式碼
利用自動佈局和約束計算高度結合estimatedRowHeight特性計算高度
先將titleLabel利用約束固定在cell上:
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.left.mas_equalTo(self.contentView).with.mas_offset(20.0f);
make.bottom.right.mas_equalTo(self.contentView).with.mas_offset(-20.0f);
}];
複製程式碼
再將UITableView設定為預估高度的模式:
self.estimatedRowHeight = 300.0f; //設定近似值
self.rowHeight = UITableViewAutomaticDimension;
複製程式碼
只需要兩行程式碼,我們就完成了動態高度的估算工作,非常的簡潔明瞭。
這裡我用了Xib載入cell和程式碼構建cell兩種方式生成cell:
//程式碼建立cell
if(!autoSizeTableViewCell){
autoSizeTableViewCell = [[RBAutoSizeTableViewCell1 alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellID];
}
//nib建立cell
if(!autoSizeTableViewCell){
autoSizeTableViewCell = [[NSBundle mainBundle] loadNibNamed:NSStringFromClass([RBAutoSizeTableViewCell2 class]) owner:self options:nil].lastObject;
}
複製程式碼
儘管很多同學都用過Xib檔案,但是對於其中的原理不甚熟悉,Xib其實就是一個XML檔案,在專案執行時會被編譯成二進位制檔案即nib檔案,Fabric將會在下文中分析Xib的執行效率。
注意:千萬不要再次重寫- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
方法,否則UITableView將不會預估高度
載入效率對比
當我接到一個需求的時候,其實腦子裡面閃現過許多實現需求的方法,到底用哪一種方法,取決於很多因素:程式碼複雜度,可擴充套件性,穩定性,程式碼執行效率等等。
今天Fabric主要從效能方面來分析兩種實現方式的優劣,下面是一張三種方式動態計算高度(我們把Xib+約束動態計算單元格高度當作第三種自適應方法),載入UITableView所需時間的柱狀圖:
當然,耗時的多少還和文字的大小有關係,Fabric為了凸顯3種方法的效率差別故意把文字內容設定的很長。正如大家看到的,程式碼動態計算高度的耗時要遠遠地高於後兩者,效率非常低下,當我們把cell總數設定為1000,甚至10000的時候,可以很明顯的感受到載入緩慢,嚴重的傷害了使用者體驗。
效能差別分析
大家可能會驚訝,短短几行程式碼,為什麼耗時的差距可以高達上萬倍呢?!
原因在於:當使用程式碼動態計算高度時,UITableView會首先執行一遍
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
return [RBAutoSizeTableViewCell calculateTitleWidth:self.titles[indexPath.row]] + 20.0*2;
}
複製程式碼
方法,當有1000個cell的時候,UITableview就會首先執行1000次計算高度的方法,然後再去執行- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
獲取cell,獲取cell之後,又會執行一次- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
方法,來獲取當前cell的高度。這樣一來,肯定要耗費非常長的時間。
反觀第二種方法,UITableView只會預載入一個UITableView contentSize的內容,也就是說,無論有多少cell,UITableView會先載入一屏內容,再預計算第二屏的高度,不會有更多的計算操作。這種預載入邏輯,保障了UITableView既不會卡頓,也不會消耗更多的資源。
另外看一下Xib+約束的執行效率,並不比純程式碼要低,可能有讀者會有疑問:
-
1、UITableView上一次性建立的Xib檔案不多所以看不出效能差別。
-
2、Xib檔案上只有一個UILabel,太簡單了,所以看不出Xib檔案的耗時。
所以Fabric把行高設定成5px,讓UITableView一次性多生成一些cell;儘量多拖拽一些控制元件到Xib上,增加Xib檔案的複雜度,執行結果顯示: 純程式碼構建cell和用Xib獲取cell沒有明顯的效能區別。因此,Xib檔案的執行效率是很高的,並不像我起先設想的那樣,讀取XML檔案會很耗時。
總結
通過動態載入單元格的效能實驗,我們知道了UITableView載入緩慢的原因:重複執行了大量的耗時操作,因此Fabric總結了以下幾點提高UITableView載入效率的方法:
- 1、不要在UITableViewDataSource的代理方法中加入過多的耗時方法,比如說計算寬高或者載入資料。
- 2、儘量複用自定義的UITableViewCell,而不是定義非常多個UITableViewCell,畢竟從快取池裡獲取cell要比重新建立cell要來的快。
- 3、對於需要反覆使用的資料建議加入快取,比如說我們要重複獲取一張名字為"Fabric"的圖片,那麼我們可以用如下程式碼:
- (UIImage *)getCellImage:(NSString *)imageName{
if(!imageName) return nil;
UIImage *img = [self.imageDict objectForKey:imageName];
if(!img){
img = [UIImage imageNamed:imageName];
[self.imageDict setValue:img forKey:imageName];
}
return img;
}
複製程式碼
當然,無論是第三方SDWebImage還是系統方法+ (nullable UIImage *)imageNamed:(NSString *)name
,都已經幫我們將圖片儲存在磁碟上了,不需要我們再次去做快取了,Fabric只是用圖片快取舉個例子而已。
- 4、儘量不要在
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
方法中,獲取到cell之後再去addSubView
,如果這樣做的話,cell每一次出現在使用者介面上就add一次subView,那麼使用者來回滑動幾次UITableView,就會發現介面卡頓,滑動明顯變慢,甚至滑不動了。 - 5、多用
hidden
屬性去隱藏對使用者不可見的控制元件,而不是通過設定alpha為0,或者設定控制元件寬高為0的方式來隱藏控制元件,因為當控制元件的hidden
屬性為YES的時候,系統會自動優化控制元件記憶體,減少裝置的資源消耗。
後續 - 提高方法一(程式碼計算高度)的UITableView的載入效率
首先,感謝兩位讀者Asuray和ControlM給Fabric的寶貴留言。他們一針見血的指出了程式碼計算高度自適應UITableView效率低下的原因:在UITableViewDataSource的代理方法中,執行了過多的冗餘的計算UILabel高度的操作。
Fabric的方法一是一個不恰當的載入UITableView的思路,旨在讓讀者看到UITableView載入效率低下的原因。下面我們來設想一下如何優化,結合上文總結的五點提高UITableView載入效率的方法,Fabric想出了以下三點改進方法:
- 1.加入快取機制,即把title的內容寫入Model,在Model中計算出UILabel的高度,避免UITableView每次獲取高度都要計算一遍高度,也避免了在獲取到Cell的時候計算UILabel高度,程式碼如下:
- (void)convertDataToModel{
[self.titles enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
RBTitleModel *titleModel = [[RBTitleModel alloc] init];
titleModel.title = obj;
titleModel.titleLabelHeight = 0.0f;
[self.titles replaceObjectAtIndex:idx withObject:titleModel];
}];
}
複製程式碼
- 2.在
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
方法中不再執行計算UILabel高度的方法,而是給出一個預設的高度,程式碼如下:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
RBTitleModel *model = [self.titles objectAtIndex:indexPath.row];
return model.titleLabelHeight + 20.0*2;
}
複製程式碼
- 3.單純的把文字高度計算放入Model中效率還是極其低下的,因為在reloadData之前,需要執行所有的Model的計算高度程式碼,假設資料來源是有1000000個元素的陣列,那麼在轉換model之前,就需要計算1000000次高度。所以最好的方法是在
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
方法中計算UILabel的高度,程式碼如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *autoSizeTableViewCellID = @"RBAutoSizeTableViewCell";
RBAutoSizeTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:autoSizeTableViewCellID];
if(!cell){
cell = [[RBAutoSizeTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:autoSizeTableViewCellID];
}
//動態計算當前cell的高度
RBTitleModel *titleModel = [self.titles objectAtIndex:indexPath.row];
[titleModel calculateTitleWidth];
[cell buildData:self.titles[indexPath.row]];
return cell;
}
複製程式碼
在計算高度時,Fabric採用了快取機制,如果titleModel.titleHeight的數值不為0,說明已經計算過高度不需要重複計算,程式碼如下:
- (void)calculateTitleWidth{
//有快取則不需要重複計算
if(self.titleLabelHeight > 0) return;
CGFloat stringWidth = 0;
CGSize size = CGSizeMake(kRBScreenWidth - 20.0f*2, MAXFLOAT);
if (self.title.length > 0) {
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 70000
stringWidth = [self.title
boundingRectWithSize:size
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName:kRBTextFont}
context:nil].size.height;
#else
//iOS7.0以下方法
stringWidth = [self.title sizeWithFont:kRBTextFont
constrainedToSize:size
lineBreakMode:NSLineBreakByCharWrapping].height;
#endif
}
self.titleLabelHeight = stringWidth;
}
複製程式碼
經過改進之後,UITableView的執行效率明顯變高了,下圖是改進之後的兩種方式的UITableView載入耗時的柱狀圖:
雖然程式碼計算高度的效率還是最低的,但是相比之前要好了很多。感興趣的同學可以去我的GitHub上下載Demo,閱讀原始碼,也可以自己動手實現一下。大家有更好的優化UITableView載入效率的方法,也可以直接在Demo中修改,然後push給Fabric,大家共同進步,一起提高技術水平。Fabric能想到的優化UITableView載入效率的方法就只有以上這麼多了,歡迎大家在文章下方留言一起探討,也可以加我的微信justlikeitRobert和我討論,喜歡這篇文章請點贊,謝謝大家的關注與支援。