近些年,App 越來越推崇體驗至上,隨隨便便亂寫一通的話已經很難讓使用者買帳了,順滑的列表便是其中很重要的一點。如果一個 App 的頁面滾動起來總是卡頓卡頓的,輕則被當作反面教材來吐槽或者襯托“我們的 App balabala…”,重則直接解除安裝。正好最近在優化這一塊兒,總結記錄下。
如果說有什麼好的部落格文章推薦,ibireme 的 iOS 保持介面流暢的技巧 這篇堪稱經典,牆裂推薦反覆閱讀。這篇文章中講解了很多的優化點,我自己總結了下收益最大的兩個優化點:
- 避免重複多次計算 cell 行高
- 文字非同步渲染
大家可以看看上面這張圖的對比分析,資料是 iPhone6 的機子用 instruments 抓的,左邊的是用 Auto Layout 繪製介面的資料分析,正常如果想平滑滾動的話,fps 至少需要穩定在 55 左右,我們可以發現,在沒有快取行高和非同步渲染的情況下 fps 是最低的,可以說是比較卡頓了,至少是能肉眼感覺出來,能滿足平滑滾動要求的也只有在快取行高且非同步渲染的情況下;右邊的是沒用 Auto Layout 直接用 frame 來繪製介面的資料分析,可以發現即使沒有非同步渲染,也能勉強滿足平滑滾動的要求,如果開啟非同步渲染的話,可以說是相當的絲滑了。
避免重複多次計算 cell 行高
TableView 行高計算可以說是個老生常談的問題了,heightForRowAtIndexPath:
是個呼叫相當頻繁的方法,在裡面做過多的事情難免會造成卡頓。 在 iOS 8 中,我們可以通過設定下面兩個屬性來很輕鬆的實現高度自適應:
self.tableView.estimatedRowHeight = 88;
self.tableView.rowHeight = UITableViewAutomaticDimension;
複製程式碼
雖然很方便,不過如果你的頁面對效能有一定要求,建議不要這麼做,具體可以看看 sunnyxx 的 優化UITableViewCell高度計算的那些事。文中針對 Auto Layout,提供了個 cell 行高的快取庫 UITableView-FDTemplateLayoutCell,可以很好的幫助我們避免 cell 行高多次計算的問題。
如果不使用 Auto Layout,我們可以在請求完拿到資料後提前計算好頁面每個控制元件的 frame 和 cell 高度,並且快取在記憶體中,用的時候直接在 heightForRowAtIndexPath:
取出計算好的值就行,大概流程如下:
- 模擬請求資料回撥:
- (void)viewDidLoad {
[super viewDidLoad];
[self buildTestDataThen:^(NSMutableArray <FDFeedEntity *> *entities) {
self.data = @[].mutableCopy;
@autoreleasepool {
for (FDFeedEntity *entity in entities) {
FrameModel *frameModel = [FrameModel new];
frameModel.entity = entity;
[self.data addObject:frameModel];
}
}
[self.tvFeed reloadData];
}];
}
複製程式碼
- 一個簡單計算 frame 、cell 行高方式:
//FrameModel.h
@interface FrameModel : NSObject
@property (assign, nonatomic, readonly) CGRect titleFrame;
@property (assign, nonatomic, readonly) CGFloat cellHeight;
@property (strong, nonatomic) FDFeedEntity *entity;
@end
複製程式碼
//FrameModel.m
@implementation FrameModel
- (void)setEntity:(FDFeedEntity *)entity {
if (!entity) return;
_entity = entity;
CGFloat maxLayout = ([UIScreen mainScreen].bounds.size.width - 20.f);
CGFloat bottom = 4.f;
//title
CGFloat titleX = 10.f;
CGFloat titleY = 10.f;
CGSize titleSize = [entity.title boundingRectWithSize:CGSizeMake(maxLayout, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading attributes:@{NSFontAttributeName : Font(16.f)} context:nil].size;
_titleFrame = CGRectMake(titleX, titleY, titleSize.width, titleSize.height);
//cell Height
_cellHeight = (CGRectGetMaxY(_titleFrame) + bottom);
}
@end
複製程式碼
- 行高取值:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
FrameFeedCell *cell = [tableView dequeueReusableCellWithIdentifier:FrameFeedCellIdentifier forIndexPath:indexPath];
FrameModel *frameModel = self.data[indexPath.row];
cell.model = frameModel;
return cell;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
FrameModel *frameModel = self.data[indexPath.row];
return frameModel.cellHeight;
}
複製程式碼
- 控制元件賦值:
- (void)setModel:(FrameModel *)model {
if (!model) return;
_model = model;
FDFeedEntity *entity = model.entity;
self.titleLabel.frame = model.titleFrame;
self.titleLabel.text = entity.title;
}
複製程式碼
優缺點
快取行高方式有現成的庫簡單方便,雖然 UITableView-FDTemplateLayoutCell 已經處理的很好了,但是 Auto Layout 對效能還是會有部分消耗;手動計算 frame 方式所有的位置都需要計算,比較麻煩,而且在資料量很大的情況下,大量的計算對資料展示時間會有部分影響,相應的回報就是效能會更好一些。
文字非同步渲染
當顯示大量文字時,CPU 的壓力會非常大。對此解決方案只有一個,那就是自定義文字控制元件,用 TextKit 或最底層的 CoreText 對文字非同步繪製。儘管這實現起來非常麻煩,但其帶來的優勢也非常大,CoreText 物件建立好後,能直接獲取文字的寬高等資訊,避免了多次計算(調整 UILabel 大小時算一遍、UILabel 繪製時內部再算一遍);CoreText 物件佔用記憶體較少,可以快取下來以備稍後多次渲染。
幸運的是,想支援文字非同步渲染也有現成的庫 YYText ,下面來講講如何搭配它最大程度滿足我們如絲般順滑的需求:
Frame 搭配非同步渲染
基本思路和計算 frame 類似,只不過把系統的 boundingRectWithSize:
、 sizeWithAttributes:
換成 YYText 中的方法:
- 配置 frame model:
//FrameYYModel.h
@interface FrameYYModel : NSObject
@property (assign, nonatomic, readonly) CGRect titleFrame;
@property (strong, nonatomic, readonly) YYTextLayout *titleLayout;
@property (assign, nonatomic, readonly) CGFloat cellHeight;
@property (strong, nonatomic) FDFeedEntity *entity;
@end
複製程式碼
//FrameYYModel.m
@implementation FrameYYModel
- (void)setEntity:(FDFeedEntity *)entity {
if (!entity) return;
_entity = entity;
CGFloat maxLayout = ([UIScreen mainScreen].bounds.size.width - 20.f);
CGFloat space = 10.f;
CGFloat bottom = 4.f;
//title
NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:entity.title];
title.yy_font = Font(16.f);
title.yy_color = [UIColor blackColor];
YYTextContainer *titleContainer = [YYTextContainer containerWithSize:CGSizeMake(maxLayout, CGFLOAT_MAX)];
_titleLayout = [YYTextLayout layoutWithContainer:titleContainer text:title];
CGFloat titleX = 10.f;
CGFloat titleY = 10.f;
CGSize titleSize = _titleLayout.textBoundingSize;
_titleFrame = (CGRect){titleX,titleY,CGSizeMake(titleSize.width, titleSize.height)};
//cell Height
_cellHeight = (CGRectGetMaxY(_titleFrame) + bottom);
}
@end
複製程式碼
對比上面 frame,可以發現多了個 YYTextLayout
屬性,這個屬性可以提前配置文字的特性,包括 font
、textColor
以及行數、行間距、內間距等等,好處就是可以把一些邏輯提前處理好,比如根據介面欄位,動態配置字型顏色,字號等,如果用 Auto Layout,這部分邏輯則不可避免的需要寫在 cellForRowAtIndexPath:
方法中。
- UITableViewCell 處理 :
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (!self) return nil;
YYLabel *title = [YYLabel new];
title.displaysAsynchronously = YES; //開啟非同步渲染
title.ignoreCommonProperties = YES; //忽略屬性
title.layer.borderColor = [UIColor brownColor].CGColor;
title.layer.cornerRadius = 1.f;
title.layer.borderWidth = 1.f;
[self.contentView addSubview:_titleLabel = title];
return self;
}
複製程式碼
- 賦值:
- (void)setModel:(FrameYYModel *)model {
if (!model) return;
_model = model;
self.titleLabel.frame = model.titleFrame;
self.titleLabel.textLayout = model.titleLayout; //直接取 YYTextLayout
}
複製程式碼
Auto Layout 搭配非同步渲染
YYText 非常友好,同樣支援 xib,YYText 繼承自 UIView
,所要做的事情也很簡單:
- 在 xib 中配置約束
- 開啟非同步屬性
開啟非同步屬性可以程式碼裡設定,也可以直接在 xib 裡設定,分別如下:
self.titleLabel.displaysAsynchronously = YES;
self.subTitleLabel.displaysAsynchronously = YES;
self.contentLabel.displaysAsynchronously = YES;
self.usernameLabel.displaysAsynchronously = YES;
self.timeLabel.displaysAsynchronously = YES;
複製程式碼
另外需要注意的一點是,多行文字的情況下需要設定最大換行寬:
CGFloat maxLayout = [UIScreen mainScreen].bounds.size.width - 20.f;
self.titleLabel.preferredMaxLayoutWidth = maxLayout;
self.subTitleLabel.preferredMaxLayoutWidth = maxLayout;
self.contentLabel.preferredMaxLayoutWidth = maxLayout;
複製程式碼
優缺點
YYText 的非同步渲染能極大程度的提高列表流暢度,真正達到如絲般順滑,但是在開啟非同步時,重新整理列表會有閃爍情況,仔細想想覺得也正常,畢竟是非同步的,渲染也需要時間,這裡作者給出了一些 方案,大家可以看看。
其它
關於圓角
列表中如果存在很多系統設定的圓角頁面導致卡頓:
label.layer.cornerRadius = 5.f;
label.clipsToBounds = YES;
複製程式碼
據觀察,只要當前螢幕內只要設定圓角的控制元件個數不要太多(大概十幾個算個臨界點),就不會引起卡頓。
還有就是隻要不設定 clipsToBounds
不管多少個,都不會卡頓,比如你需要圓角的控制元件是白色背景色的,然後它的父控制元件也是白色背景色的,而且沒有點選後高亮的,就沒必要 clipsToBounds 了。
如何定位卡頓原因
我們可以利用 instruments 中的 Time Profiler 來幫助我們定位問題位置,選中 Xcode,command + control + i 開啟:
我們選中主執行緒,去掉系統的方法,然後操作一下列表,再擷取一段呼叫資訊,可以發現我們自己實現的方法並沒有消耗多少時間,反而是系統的方法很費時,這也是卡頓的原因之一:
另外有的人 instruments 看不到方法呼叫棧(右邊一對黑色的方法資訊),去 Xcode 設定下就行了:
總結
YYText 和 UITableView-FDTemplateLayoutCell 搭配可以很大程度的提高列表流暢度:
-
如果時間比較緊迫,可以直接採取 Auto Layout + UITableView-FDTemplateLayoutCell + YYText 方式
-
如果列表中文字不包含富文字,僅僅顯示文字,又不想引入這兩個庫,可以使用系統方式提前計算 Frame
-
如果想最大程度的流暢度,就需要提前計算 Frame + YYText,具體大家根據自己情況選擇合適的方案就行
iOS 中關於列表滾動流暢方案的一些探討