一、前言
- UITableView 的優化主要從四個方面入手:
-
- 提前計算并快取好高度(布局),因為 tableView:heightForRowAtIndexPath: 是呼叫最頻繁的方法;
-
- 滑動時按需加載,防止卡頓,這個在大量圖片展示,網路加載的時候很管用,配合 SDWebImage;
-
- 異步繪制,遇到復雜界面,遇到性能瓶頸時,可能就是突破口;
-
- 快取一切可以快取的,這個在開發的時候,往往是性能優化最多的方向,
- 大概需要關注的:
-
- cell 復用;
-
- cell 高度的計算;
-
- 渲染(混合問題);
-
- 減少視圖的數目(重寫 drawRect:);
-
- 減少多余的繪制操作;
-
- 不要給 cell 動態添加 subView;
-
- 異步化 UI,不要阻塞主執行緒;
-
- 滑動時按需加載對應的內容,
二、Cell 復用
- UITableView 最核心的思想就是 UITableViewCell 的重用機制,簡單的理解就是:UITableView 只會創建一螢屏(或一螢屏多一點)的 UITableViewCell,其它都是從中取出來重用的,每當 Cell 滑出螢屏時,就會放入到一個集合(或陣列)中(相當于一個重用池),當要顯示某一位置的 Cell 時,會先去集合(或陣列)中取,如果有,就直接拿來顯示;如果沒有,才會創建,這樣做的好處可想而知,極大的減少了記憶體的開銷,
- 了解了 UITableViewCell 的重用原理后,來看看 UITableView 的回呼方法,UITableView 最主要的兩個回呼方法:
tableView:cellForRowAtIndexPath:
tableView:heightForRowAtIndexPath:
- 理想上我們是會認為 UITableView 會先呼叫前者,再呼叫后者,因為這和創建控制元件的思路是一樣的,先創建它,再設定它的布局,但實際上卻并非如此,
- 我們都知道,UITableView 是繼承自 UIScrollView 的,需要先確定它的 contentSize 及每個 Cell 的位置,然后才會把重用的 Cell 放置到對應的位置,所以事實上,UITableView 的回呼順序是先多次呼叫 tableView:heightForRowAtIndexPath: 以確定 contentSize 及 Cell 的位置,然后才會呼叫 tableView:cellForRowAtIndexPath:,從而來顯示在當前螢屏的 Cell,
- 因此,在可見的頁面會重復繪制頁面,每次重繪顯示都會去創建新的 Cell,非常耗費性能, 解決方案就是創建一個靜態變數 reuseID,防止重復創建(提高性能),使用系統的快取池功能,
// 呼叫次數太多,static 保證只創建一次 reuseID,提高性能
static NSString *kCELL_RUID = @"Cell";
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
// 快取池中取已創建的 cell
UITableViewCell * cell = [tableView dequeueReusableCellWithIdentifier:kCELL_RUID
forIndexPath:indexPath];
return cell;
}
- 通過 identifier 標識不同型別的 cell,快取池中只會保存已經被移出螢屏的不同型別的 cell,復用 Cell 時 不會呼叫 awakeFromNib:
- (nullable __kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier; // Used by the delegate to acquire an already allocated cell, in lieu of allocating a new one.
- (__kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(6_0); // newer dequeue method guarantees a cell is returned and resized properly, assuming identifier is registered
- 兩個獲取方法的區別:
-
- dequeueReusableCellWithIdentifier:forIndexPath 如果沒有注冊復用 identifier,執行這句時會崩潰,提示:
reason: 'unable to dequeue a cell with identifier CELL - must register a nib or a class for the identifier or connect a prototype cell in a storyboard'
-
- dequeueReusableCellWithIdentifier 如果沒有注冊復用 identifier,陳述句回傳 nil,繼續執行會崩潰,提示:
failed to obtain a cell from its dataSource
-
- 判斷 nil 后可以自己創建 cell,如下:
MyCell * cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
if (cell == nil) {
cell = [[MyCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell"];
}
- 為什么需要 forIndexPath:?
-
- 因為在回傳 cell 之前,會呼叫委托 tableView:heightForRowAtIndexPath:來確定 cell 尺寸(如果已經定義該函式),
-
- 我們經常在 tableView:cellForRowAtIndexPath: 中為每一個 cell 系結資料,實際上在呼叫 cellForRowAtIndexPath: 的時候 cell 還沒有被顯示出來,為了提高效率應該把資料系結的操作放在 cell 顯示出來后再執行,可以在 tableView:willDisplayCell:forRowAtIndexPath: 方法中系結資料,
-
- 注意 willDisplayCell 中 cell 在 tableview 展示之前就會呼叫,此時 cell 實體已經生成,所以不能更改 cell 的結構,只能是改動 cell 上的 UI 的一些屬性,如 label 的內容、控制元件的隱藏等,
三、定義一種(盡量少)型別的 Cell 及善用 hidden 隱藏(顯示)subviews
- 分析 Cell 結構,盡可能的將相同內容的抽取到一種樣式 Cell 中,UITableView 真正創建出的 Cell 可能只比螢屏顯示的多一點,雖然 Cell 的“體積”可能會大點,但是因為 Cell 的數量不會很多,完全可以接受的,
- 這樣的好處就是:
-
- 減少代碼量,減少 Nib 檔案的數量,在一個 Nib 檔案定義 Cell,容易修改、維護;
-
- 基于復用機制,真正運行時鋪滿螢屏所需的 Cell 數量大致是固定的,設為 N 個,如果只有一種 cell,那就是只有 N + c 個 cell 的實體;但是如果有 M 種 cell,那么運行時最多可能會是 M * (N + c) 個 cell 的實體,雖然這可能并不會占用太多記憶體,但能少一些更好,
- 既然只定義一種 Cell,那么需要把所有不同型別的 view 都定義好,放在 Cell 里面,通過 hidden 屬性控制,來顯示不同型別的內容,畢竟,在用戶快速滑動中,只是單純的顯示/隱藏 subview 比實時創建要快得多,
- 盡量少用 [cell addSubview:] 動態添加 View,可以初始化時就添加,然后通過 hidden 屬性來控制,
四、提前計算并快取 Cell 的高度
① rowHeight
- UITableView 詢問 cell 高度有兩種方式:
-
- 一種是針對所有 Cell 具有固定高度的情況,通過:
self.tableView.rowHeight = 88;
-
- 直接采用上面方式給定高度,不需要實作 tableView:heightForRowAtIndexPath: 以節省不必要的計算和開銷,指定一個所有 cell 都是 88 高度的 UITableView,對于定高需求的表格,強烈建議使用這種(而非下面的)方式保證不必要的高度計算和呼叫,rowHeight 屬性的默認值是 44,
-
- 另一種方式就是實作 UITableViewDelegate 中的:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// return xxx
}
-
- 實作了這個方法后,rowHeight 的設定將無效,因此這個方法適用于具有多種 cell 高度的 UITableView,
② estimatedRowHeight
- iOS7 就出現這個屬性, 檔案是這么描述它的作用:
If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
- UITableView 是個 UIScrollView,就像平時使用 UIScrollView 一樣,加載時指定 contentSize 后它才能根據自己的 bounds、contentInset、contentOffset 等屬性共同決定是否可以滑動以及滾動條的長度,而 UITableView 在一開始并不知道自己會被填充多少內容,于是詢問 data source 個數和創建 cell,同時詢問 delegate 這些 cell 應該顯示的高度,這就造成它在加載的時候浪費了多余的計算在螢屏外邊的 cell 上,
- 和上面的 rowHeight 很類似,設定這個估算高度有兩種方法:
self.tableView.estimatedRowHeight = 88;
// or
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
// return xxx
}
- 有所不同的是,即使面對種類不同的 cell,依然可以使用簡單的 estimatedRowHeight 屬性賦值,只要整體估算值接近就可以,比如大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就可以估算一個 66,基本符合預期,
- 說完了估算高度的基本使用,會有以下問題:
-
- 設定估算高度后,contentSize.height 根據“cell估算值 x cell個數”計算,這就導致滾動條的大小處于不穩定的狀態,contentSize 會隨著滾動從估算高度慢慢替換成真實高度,肉眼可見滾動條突然變化甚至“跳躍”;
-
- 若是有設計不好的下拉重繪或上拉加載控制元件,或是 KVO 了 contentSize 或 contentOffset 屬性,有可能使表格滑動時跳動;
-
- 估算高度設計初衷是好的,讓加載速度更快,那憑啥要去侵害滑動的流暢性呢,用戶可能對進入頁面時多零點幾秒加載時間感覺不大,但是滑動時實時計算高度帶來的卡頓是明顯能體驗到的,個人覺得還不如一開始都算好(iOS8 之后更過分,即使都算好了也會邊劃邊計算),
- 因此,tableView:estimatedHeightForRowAtIndexPath: -> tableView:heightForRowAtIndexPath: 獲取每個 Cell 即將顯示的高度,從而確定表格視圖的布局,實際是要獲取滾動視圖的 contentSize,然后呼叫 tableView:cellForRowAtIndexPath:,獲取每個 Cell,進行賦值,如果有很多個 Cell 要顯示,那么方法會執行很多次,
- 解決方案:在 Model(Entity)中計算并保存 Cell 的高度,其實 Model 中保存 UI 的引數是很奇怪的,最好放在 MVVM 模式的 ViewModel(視圖模型)中,讓 Model(資料模型)只負責處理資料,
@interface Model : NSObject
@property (nonatomic, assign) CGFloat cellHeight; // Cell 高度
/**
* @brief 計算高度
*/
- (void)calculateCellHeight;
@end
- 在 tableView:heightForRowAtIndexPath: 中盡量不使用 cellForRowAtIndexPath: 方法來獲取 cell,如果需要用到它,只用一次然后快取結果,
- 還可以繼續進行優化,提前創建真正顯示的、需要加工的資料并快取,如:介面回傳 NSString 而展示 NSAttributeString,
③ iOS8 self-sizing cell
-
- 具有動態高度內容的 cell 一直是個頭疼的問題,比如聊天氣泡的 cell, frame 布局時代通常是用資料內容反算高度:
CGFloat height = textHeightWithFont() + imageHeight + topMargin + bottomMargin + ...;
- 供 UITableViewDelegate 呼叫時很可能是個 cell 的類方法:
@interface BubbleCell : UITableViewCell
+ (CGFloat)heightWithEntity:(id)entity;
@end
- AutoLayout 時代好了不少,提供了 -systemLayoutSizeFittingSize: 的 API,在 contentView 中設定約束后,就能計算出準確的值;缺點是計算速度肯定沒有手算快,而且這是個實體方法,需要維護專門為計算高度而生的 template layout cell,它還要求使用者對約束設定的比較熟練,要保證 contentView 內部上下左右所有方向都有約束支撐,設定不合理的話計算的高度就成了0,
- 這里不得不提到一個 UILabel 的蛋疼問題,當 UILabel 行數大于 0 時,需要指定 preferredMaxLayoutWidth 后它才知道自己什么時候該換行,因為 UILabel 需要知道 superview 的寬度才能換,而 superview 的寬度還依仗著子 view 寬度的累加才能確定,
- 自從 iOS8 之后,有了 self-sizing Cell 的概念,Cell 可以自己算出高度,使用 self-sizing cell 需要滿足以下三個條件:
-
- 使用 AutoLayout 進行 UI 布局約束,要求 cell.contentView 的四條邊都與內部元素有約束關系;
-
- 指定 TableView 的 estimatedRowHeight 屬性的默認值;
-
- 指定 TableView 的 rowHeight 屬性為 UITableViewAutomaticDimension;
self.tableView.estimatedRowHeight = 44.0;
self.tableView.rowHeight = UITableViewAutomaticDimension;
- 這里又不得不吐槽,自動計算 rowHeight 跟 estimatedRowHeight 到底是有什么仇,如果不加上估算高度的設定,自動算高就失效了,iOS8 系統中 rowHeight 的默認值已經設定成了 UITableViewAutomaticDimension,所以第二行代碼可以省略,
④ UITableView+FDTemplateLayoutCell
- 使用 UITableView+FDTemplateLayoutCell 無疑是解決算高問題的最佳實踐之一,既有 iOS8 以后的 self-sizing 功能簡單的 API,又可以達到 iOS7 流暢的滑動效果,還保持了最低支持 iOS6,這個開源的擴展,請參考:UITableView-FDTemplateLayoutCell,
- 使用 Cocoapods 可以直接安裝:
pod search UITableView+FDTemplateLayoutCell
- 使用起來大概是這樣:
#import <UITableView+FDTemplateLayoutCell.h>
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [tableView fd_heightForCellWithIdentifier:@"identifer" cacheByIndexPath:indexPath configuration:^(id cell) {
// 配置 cell 的資料源,和 "cellForRow" 干的事一致,比如:
cell.entity = self.feedEntities[indexPath.row];
}];
}
- 以上代碼,產生的優化收益:
-
- 和每個 UITableViewCell ReuseID 一一對應的 template layout cell,這個 cell 只為參加高度計算,不會真的顯示到螢屏上;它通過 UITableView 的 -dequeueCellForReuseIdentifier: 方法 lazy 創建并保存,所以要求這個 ReuseID 必須已經被注冊到了 UITableView 中,也就是說,要么是 Storyboard 中的原型 cell,要么就是使用了 UITableView 的 -registerClass:forCellReuseIdentifier: 或 -registerNib:forCellReuseIdentifier:其中之一的注冊方法;
-
- 根據 autolayout 約束自動計算高度:使用系統提供的 API:-systemLayoutSizeFittingSize:;
-
- 根據 index path 的一套高度快取機制:計算出的高度會自動進行快取,所以滑動時每個 cell 真正的高度計算只會發生一次,后面的高度詢問都會命中快取,減少了非常可觀的多余計算;
-
- 自動的快取失效機制:無須擔心資料源的變化引起的快取失效,當呼叫如 -reloadData,-deleteRowsAtIndexPaths:withRowAnimation: 等任何一個觸發 UITableView 重繪機制的方法時,已有的高度快取將以最小的代價執行失效,如洗掉一個 indexPath 為 [0:5] 的 cell 時,[0:0] ~ [0:4] 的高度快取不受影響,而 [0:5] 后面所有的快取值都向前移動一個位置;自動快取失效機制對 UITableView 的 9 個公有 API 都進行了分別的處理,以保證沒有一次多余的高度計算;
-
- 預快取機制:預快取機制將在 UITableView 沒有滑動的空閑時刻執行,計算和快取那些還沒有顯示到螢屏中的 cell,整個快取程序完全沒有感知,這使得完整串列的高度計算既沒有發生在加載時,又沒有發生在滑動時,同時保證了加載速度和滑動流暢性,
⑤ 在 Model(Entity)中計算并保存 Cell 的高度
- 在 Model(Entity)中保存 UI 的引數是很奇怪的,最好放在 ViewModel 中,就是 MVVM 模式的,那么 Entity 可能就是如下樣子:
@interface DataEntity : NSObject
// 原始資料
@property(copy, nonatomic) NSString *content;
@property(copy, nonatomic) NSString *title;
// Cell 高度
@property(assign, nonatomic) CGFloat cellHeight;
// 計算高度
- (void)calculateCellHeight;
@end
- 這樣,就不用在 tableView:heightForRowAtIndexPath: 中每次都計算 cell 的高度,
五、異步繪制(自定義 Cell 繪制)
- 遇到比較復雜的界面時(復雜點的圖文混排),上面快取行高的方式可能就不能滿足要求,繪制的各個資訊都是根據之前算好的布局進行繪制的,那么就需要異步繪制:
/**
* @brief cell 添加 draw 方法
*/
- (void)draw {
// 異步繪制
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
});
}
/**
* @brief 重寫 drawRect: 方法
*/
- (void)drawRect:(CGRect)rect {
// 不需要用 GCD 異步執行緒,因為 drawRect: 本來就是異步繪制的
}
- 具體分析可以參考:詳細整理UITableView優化技巧,
六、滑動時,按需加載
- 自定義 Cell 的種類千奇百怪,但它本來就是用來顯示資料的,幾乎百分之百的時候都帶有圖片,這個時候就要考慮,下滑的程序中可能會有點卡頓,尤其網路不好的時候,異步加載圖片是個程式員都會想到,但是如果給每個回圈物件都加上異步加載,開啟的執行緒太多,一樣會卡頓,
- 這個時候,利用 UIScrollViewDelegate 兩個代理方法就能很好地解決這個問題,如下所示:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if (needLoadArr.count > 0 && [needLoadArr indexOfObject:indexPath] == NSNotFound) {
[cell clear]; // 清掉內容
}
return cell;
}
// 按需加載 - 如果目標行與當前行相差超過指定行數,只在目標滾動范圍的前后指定 3 行加載
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
NSIndexPath * ip = [self.tableView indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
NSIndexPath * cip = [[self.tableView indexPathsForVisibleRows] firstObject];
NSInteger skipCount = 8;
// -8 < 當前位置 - 目標位置 < 8
if (labs(cip.row - ip.row) > skipCount) {
// 目標區域的 cell 的 indexPaths
NSArray * temp = [self.tableView indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.tableView.frame.size.width, self.tableView.frame.size.height)];
NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
if (velocity.y < 0) {
NSIndexPath *indexPath = [temp lastObject];
if (indexPath.row + 33) {
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row - 3 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row - 2 inSection:0]];
[arr addObject:[NSIndexPath indexPathForRow:indexPath.row - 1 inSection:0]];
}
}
[needLoadArr addObjectsFromArray:arr];
}
}
- 識別 UITableView 拖拽即將結束的時候,進行異步加載圖片,快滑動程序中,只加載目標范圍內的 Cell,這樣按需加載,極大的提高流暢度,而 SDWebImage 可以實作異步加載,與這條性能配合就完美了,尤其是大量圖片展示的時候,而且也不用擔心圖片快取會造成記憶體警告的問題,
七、快取 View
- 當 Cell 中的部分 View 是非常獨立且不便于重用的,"體積"非常小,在記憶體可控的前提下,完全可以將這些 view 快取起來,
- 方法當然也是將快取的 view 放在 Entity 中,
八、盡量顯示大小剛好合適的圖片資源
- 避免大量的圖片縮放、顏色漸變等,
九、避免同步的從網路、檔案獲取資料
- Cell 內實作的內容來自 web,使用異步加載,快取請求結果,
十、渲染
- 減少 subviews 的個數和層級:子控制元件的層級越深,渲染到螢屏上所需要的計算量就越大;如多用 drawRect 繪制元素,替代用 view 顯示,
- 少用 subviews 的透明圖層:渲染最耗時的操作之一就是混合(blending)了,對于不透明的 View,設定 opaque = YES,這樣在繪制該 View 時,避免 GPU 對 View 覆寫的其他內容也進行繪制,
- 背景色不要使用 clearColor;
- 避免 CALayer 特效(shadowPath):給 Cell 中 View 加陰影會引起性能問題,如下面代碼會導致滾動時有明顯的卡頓:
view.layer.shadowColor = color.CGColor;
view.layer.shadowOffset = offset;
view.layer.shadowOpacity = 1;
view.layer.shadowRadius = radius;
- 當有影像時,預渲染影像,在 bitmap context 先將其畫一遍,匯出成 UIImage 物件,然后再繪制到螢屏,這會大大提高渲染速度,
十一、異步加載影像
- 快取影像可以幫助我們在應用程式中快速實體化 tableView,并快速回應滾動,圖片不是資產目錄的一部分,而是應用包的一部分,用來模擬通過 URL 異步加載每個圖片,這確保了用戶界面保持回應性,
- 當用戶在視圖中滾動時,應用程式會反復請求相同的影像,保存相關的完成模塊直到影像加載,然后將影像傳遞給所有請求塊,因此 API 只需要呼叫一次就可以為給定 URL 獲取影像,
- 如下所示,展示了專案如何構造一個基本的快取和加載方法:
final func load(url: NSURL, item: Item, completion: @escaping (Item, UIImage?) -> Swift.Void) {
// Check for a cached image.
if let cachedImage = image(url: url) {
DispatchQueue.main.async {
completion(item, cachedImage)
}
return
}
// In case there are more than one requestor for the image, we append their completion block.
if loadingResponses[url] != nil {
loadingResponses[url]?.append(completion)
return
} else {
loadingResponses[url] = [completion]
}
// Go fetch the image.
ImageURLProtocol.urlSession().dataTask(with: url as URL) { (data, response, error) in
// Check for the error, then data and try to create the image.
guard let responseData = data, let image = UIImage(data: responseData),
let blocks = self.loadingResponses[url], error == nil else {
DispatchQueue.main.async {
completion(item, nil)
}
return
}
// Cache the image.
self.cachedImages.setObject(image, forKey: url, cost: responseData.count)
// Iterate over each requestor for the image and pass it back.
for block in blocks {
DispatchQueue.main.async {
block(item, image)
}
return
}
}.resume()
}
- 在啟動時加載所有資料的應用有耗盡記憶體或因耗時太長而終止的風險,除非應用程式需要在操作前加載所有資料,否則在 UI 請求時加載影像,
- 通常,應用程式應該等到資料源請求一個單元格來獲取和設定一個影像,如下,演示在可重用視圖中獲取和顯示影像的一種方法:
var content = cell.defaultContentConfiguration()
content.image = item.image
ImageCache.publicCache.load(url: item.url as NSURL, item: item) { (fetchedItem, image) in
if let img = image, img != fetchedItem.image {
var updatedSnapshot = self.dataSource.snapshot()
if let datasourceIndex = updatedSnapshot.indexOfItem(fetchedItem) {
let item = self.imageObjects[datasourceIndex]
item.image = img
updatedSnapshot.reloadItems([item])
self.dataSource.apply(updatedSnapshot, animatingDifferences: true)
}
}
}
cell.contentConfiguration = content
十二、參考資料
- VVeboTableViewDemo;
- UITableView-FDTemplateLayoutCell;
- LazyTableImages,
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/333839.html
標籤:其他
