深入源码 UITableView 复用技术原理分析
在現在很多公司的 app 中,許多展示頁面為了多條數據內容,而采用 UITableView 來設計頁面。在滑動 UITableView 的時候,并不會因為數據量大而產生卡頓的情況,這正是因為其復用機制的特點。但是其復用機制是如何實現的?我們可以一起來看看
Chameleon
Chameleon用于將 iOS 的功能遷移到macOS上 并且在其中為 macOS 實現了一套與 iOS UIKit 同名的框架 并且其代碼為開源. 所以我們可以來研究一下思路
首先 下載Chameleon 然后打開 UIKit 項目
UITableView 的初始化方法
當我們定義一個 UITableView 對象的時候,需要對這個對象進行初始化。最常用的方法莫過于 - (id)initWithFrame:(CGRect)frame style:(UITableViewStyle)theStyle。下面跟著這個初始化入口,逐漸來分析代碼:
- (id)initWithFrame:(CGRect)frame style:(UITableViewStyle)theStyle {if ((self=[super initWithFrame:frame])) {// 確定 style_style = theStyle;// cell 緩存字典_cachedCells = [[NSMutableDictionary alloc] init];// section 緩存數組_sections = [[NSMutableArray alloc] init];// 復用 cell 可變集合_reusableCells = [[NSMutableSet alloc] init];// 基本屬性設置self.separatorColor = [UIColor colorWithRed:.88f green:.88f blue:.88f alpha:1];self.separatorStyle = UITableViewCellSeparatorStyleSingleLine;self.showsHorizontalScrollIndicator = NO;self.allowsSelection = YES;self.allowsSelectionDuringEditing = NO;self.sectionHeaderHeight = self.sectionFooterHeight = 22;self.alwaysBounceVertical = YES;if (_style == UITableViewStylePlain) {self.backgroundColor = [UIColor whiteColor];}// 加入 Layout 標記,進行手動觸發布局設置[self _setNeedsReload];}return self; }復制代碼在初始化代碼中就看到了重點,_cachedCells、_sections 和 _reusableCells 無疑是復用的核心成員。
來繼續代碼跟蹤
我們先來查看一下 _setNeedsReload 方法中做了什么:
- (void)_setNeedsReload {_needsReload = YES;[self setNeedsLayout]; } 復制代碼首先先對?_needsReload?進行標記,之后調用了?setNeedsLayout?方法。對于?UIView的?setNeedsLayout?方法,在調用后?Runloop?會在即將到來的周期中來檢測?displayIfNeeded?標記,如果為?YES?則會進行?drawRect ?視圖重繪。作為 Apple?UIKit層中的基礎 Class,在屬性變化后都會進行一次視圖重繪的過程。這個屬性過程的變化即為對象的初始化加載以及手勢交互過程。這也就是官方文檔中的?The Runtime Interaction Model。
當 Runloop 到來時,開始重繪過程即調用 layoutSubViews 方法。在 UITableView 中這個方法已經被重寫過:
- (void)layoutSubviews {// 會在初始化的末尾手動調用重繪過程// 并且 UITableView 是 UIScrollView 的繼承,會接受手勢// 所以在滑動 UITableView 的時候也會調用_backgroundView.frame = self.bounds;// 根據標記確定是否執行數據更新操作[self _reloadDataIfNeeded];[self _layoutTableView];[super layoutSubviews]; } 復制代碼接下來我們開始查看 _reloadDataIfNeeded 以及 reloadData 方法:
- (void)_reloadDataIfNeeded {// 查詢 _needsReload 標記if (_needsReload) {[self reloadData];} }- (void)reloadData {// 清除之前的緩存并刪除 Cell[[_cachedCells allValues] makeObjectsPerformSelector:@selector(removeFromSuperview)];[_cachedCells removeAllObjects];// 復用 Cell Set 也進行刪除操作[_reusableCells makeObjectsPerformSelector:@selector(removeFromSuperview)];[_reusableCells removeAllObjects];// 刪除選擇的 Cell_selectedRow = nil;// 刪除被高亮的 Cell_highlightedRow = nil;// 更新緩存中狀態[self _updateSectionsCache];// 設置 Size[self _setContentSize];_needsReload = NO; } 復制代碼當 reloadData 方法被觸發時,UITableView 默認為在這個 UITableView 中的數據將會全部發生變化。測試之前遺留下的緩存列表以及復用列表全部都喪失了利用性。為了避免出現懸掛指針的情況(有可能某個 cell 被其他的視圖進行了引用),我們需要對每個 cell 進行 removeFromSuperview 處理,這個處理即針對于容器 UITableView,又對其他的引用做出保障。然后我們更新當前 tableView 中的兩個緩存容器,_reusableCells 和 _cachedCells,以及其他需要重置的成員屬性。
最關鍵的地方到了,緩存狀態的更新方法 _updateSectionsCache,其中涉及到數據如何存儲、如何復用的操作
- (void)_updateSectionsCache {// 使用 dataSource 來創建緩存容器// 如果沒有 dataSource 則放棄重用操作// 在這個逆向工程中并沒有對 header 進行緩存操作,但是 Apple 的 UIKit 中一定也做到了// 真正的 UIKit 中應該會獲取更多的數據進行存儲,并實現了 TableView 中所有視圖的復用// 先移除每個 Section 的 Header 和 Footer 視圖for (UITableViewSection *previousSectionRecord in _sections) {[previousSectionRecord.headerView removeFromSuperview];[previousSectionRecord.footerView removeFromSuperview];}// 清除舊緩存,對容器進行初始化操作[_sections removeAllObjects];if (_dataSource) {// 根據 dataSource 計算高度和偏移量const CGFloat defaultRowHeight = _rowHeight ?: _UITableViewDefaultRowHeight;// 獲取 Section 數目const NSInteger numberOfSections = [self numberOfSections];for (NSInteger section=0; section<numberOfSections; section++) {const NSInteger numberOfRowsInSection = [self numberOfRowsInSection:section];UITableViewSection *sectionRecord = [[UITableViewSection alloc] init];sectionRecord.headerTitle = _dataSourceHas.titleForHeaderInSection? [self.dataSource tableView:self titleForHeaderInSection:section] : nil;sectionRecord.footerTitle = _dataSourceHas.titleForFooterInSection? [self.dataSource tableView:self titleForFooterInSection:section] : nil;sectionRecord.headerHeight = _delegateHas.heightForHeaderInSection? [self.delegate tableView:self heightForHeaderInSection:section] : _sectionHeaderHeight;sectionRecord.footerHeight = _delegateHas.heightForFooterInSection ? [self.delegate tableView:self heightForFooterInSection:section] : _sectionFooterHeight;sectionRecord.headerView = (sectionRecord.headerHeight > 0 && _delegateHas.viewForHeaderInSection)? [self.delegate tableView:self viewForHeaderInSection:section] : nil;sectionRecord.footerView = (sectionRecord.footerHeight > 0 && _delegateHas.viewForFooterInSection)? [self.delegate tableView:self viewForFooterInSection:section] : nil;// 先初始化一個默認的 headerView ,如果沒有直接設置 headerView 就直接更換標題if (!sectionRecord.headerView && sectionRecord.headerHeight > 0 && sectionRecord.headerTitle) {sectionRecord.headerView = [UITableViewSectionLabel sectionLabelWithTitle:sectionRecord.headerTitle];}// Footer 也做相同的處理if (!sectionRecord.footerView && sectionRecord.footerHeight > 0 && sectionRecord.footerTitle) {sectionRecord.footerView = [UITableViewSectionLabel sectionLabelWithTitle:sectionRecord.footerTitle];}if (sectionRecord.headerView) {[self addSubview:sectionRecord.headerView];} else {sectionRecord.headerHeight = 0;}if (sectionRecord.footerView) {[self addSubview:sectionRecord.footerView];} else {sectionRecord.footerHeight = 0;}// 為高度數組動態開辟空間CGFloat *rowHeights = malloc(numberOfRowsInSection * sizeof(CGFloat));// 初始化總高度CGFloat totalRowsHeight = 0;for (NSInteger row=0; row<numberOfRowsInSection; row++) {// 獲取 Cell 高度,未設置則使用默認高度const CGFloat rowHeight = _delegateHas.heightForRowAtIndexPath? [self.delegate tableView:self heightForRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:section]] : defaultRowHeight;// 記錄高度rowHeights[row] = rowHeight;// 總高度統計totalRowsHeight += rowHeight;}sectionRecord.rowsHeight = totalRowsHeight;[sectionRecord setNumberOfRows:numberOfRowsInSection withHeights:rowHeights];free(rowHeights);// 緩存高度記錄[_sections addObject:sectionRecord];}} } 復制代碼我們發現在 _updateSectionsCache 更新緩存狀態的過程中對 _sections 中的數據全部清除。之后緩存了更新后的所有 Section 數據。那么這些數據有什么利用價值呢?繼續來看布局更新操作。
- (void)_layoutTableView {// 在需要渲染時放置需要的 Header 和 Cell// 緩存所有出現的單元格,并添加至復用容器// 之后那些不顯示但是已經出現的 Cell 將會被復用// 獲取容器視圖相對于父類視圖的尺寸及坐標const CGSize boundsSize = self.bounds.size;// 獲取向下滑動偏移量const CGFloat contentOffset = self.contentOffset.y;// 獲取可視矩形框的尺寸const CGRect visibleBounds = CGRectMake(0,contentOffset,boundsSize.width,boundsSize.height);// 表高紀錄值CGFloat tableHeight = 0;// 如果有 header 則需要額外計算if (_tableHeaderView) {CGRect tableHeaderFrame = _tableHeaderView.frame;tableHeaderFrame.origin = CGPointZero;tableHeaderFrame.size.width = boundsSize.width;_tableHeaderView.frame = tableHeaderFrame;tableHeight += tableHeaderFrame.size.height;}// availableCell 記錄當前正在顯示的 Cell// 在滑出顯示區之后將添加至 _reusableCellsNSMutableDictionary *availableCells = [_cachedCells mutableCopy];const NSInteger numberOfSections = [_sections count];[_cachedCells removeAllObjects];// 滑動列表,更新當前顯示容器for (NSInteger section=0; section<numberOfSections; section++) {CGRect sectionRect = [self rectForSection:section];tableHeight += sectionRect.size.height;if (CGRectIntersectsRect(sectionRect, visibleBounds)) {const CGRect headerRect = [self rectForHeaderInSection:section];const CGRect footerRect = [self rectForFooterInSection:section];UITableViewSection *sectionRecord = [_sections objectAtIndex:section];const NSInteger numberOfRows = sectionRecord.numberOfRows;if (sectionRecord.headerView) {sectionRecord.headerView.frame = headerRect;}if (sectionRecord.footerView) {sectionRecord.footerView.frame = footerRect;}for (NSInteger row=0; row<numberOfRows; row++) {// 構造 indexPath 為代理方法準備NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];// 獲取第 row 個坐標位置CGRect rowRect = [self rectForRowAtIndexPath:indexPath];// 判斷當前 Cell 是否與顯示區域相交if (CGRectIntersectsRect(rowRect,visibleBounds) && rowRect.size.height > 0) {// 首先查看 availableCells 中是否已經有了當前 Cell 的存儲// 如果沒有,則請求 tableView 的代理方法獲取 CellUITableViewCell *cell = [availableCells objectForKey:indexPath] ?: [self.dataSource tableView:self cellForRowAtIndexPath:indexPath];// 由于碰撞檢測生效,則按照邏輯需要更新 availableCells 字典if (cell) {// 獲取到 Cell 后,將其進行緩存操作[_cachedCells setObject:cell forKey:indexPath];[availableCells removeObjectForKey:indexPath];cell.highlighted = [_highlightedRow isEqual:indexPath];cell.selected = [_selectedRow isEqual:indexPath];cell.frame = rowRect;cell.backgroundColor = self.backgroundColor;[cell _setSeparatorStyle:_separatorStyle color:_separatorColor];[self addSubview:cell];}}}}}// 將已經退出屏幕且定義 reuseIdentifier 的 Cell 加入可復用 Cell 容器中for (UITableViewCell *cell in [availableCells allValues]) {if (cell.reuseIdentifier) {[_reusableCells addObject:cell];} else {[cell removeFromSuperview];}}// 不能復用的 Cell 會直接銷毀,可復用的 Cell 會存儲在 _reusableCells// 確保所有的可用(未出現在屏幕上)的復用單元格在 availableCells 中// 這樣緩存的目的之一是確保動畫的流暢性。在動畫的幀上都會對顯示部分進行處理,重新計算可見 Cell。// 如果直接刪除掉所有未出現在屏幕上的單元格,在視覺上會觀察到突然消失的動作// 整體動畫具有跳躍性而顯得不流暢// 把在可視區的 Cell(但不在屏幕上)已經被回收為可復用的 Cell 從視圖中移除NSArray* allCachedCells = [_cachedCells allValues];for (UITableViewCell *cell in _reusableCells) {if (CGRectIntersectsRect(cell.frame,visibleBounds) && ![allCachedCells containsObject: cell]) {[cell removeFromSuperview];}}if (_tableFooterView) {CGRect tableFooterFrame = _tableFooterView.frame;tableFooterFrame.origin = CGPointMake(0,tableHeight);tableFooterFrame.size.width = boundsSize.width;_tableFooterView.frame = tableFooterFrame;} } 復制代碼如果你已經對 UITableView 的緩存機制有所了解,那么你在閱讀完代碼之后會對其有更深刻的認識。如果看完代碼還是一頭霧水,那么請繼續看下面的分析。
Cell 復用 的三個階段
1 布局方法觸發階段
在用戶觸摸屏幕后,硬件報告觸摸時間傳遞至 UIKit 框架,之后 UIKit 將觸摸事件打包成 UIEvent 對象,分發至指定視圖。這時候其視圖就會做出相應,并調用 setNeedsLayout 方法告訴視圖及其子視圖需要進行布局更新。此時,setNeedsLayout 被調用,也就變為 Cell 復用場景的入口。
2緩存 Cell 高度信息階段
當視圖加載后,由 UIKit 調用布局方法 layoutSubviews 從而進入緩存 Cell 高度階段 _updateSectionsCache。在這個階段,通過代理方法 heightForRowAtIndexPath: 獲取每一個 Cell 的高度,并將高度信息緩存起來。這其中的高度信息由 UITableViewSection 的一個實例 sectionRecord 進行存儲,其中以 section 為單位,存儲每個 section 中各個 Cell 的高度、Cell 的數量、以及 section 的總高度、footer 和 header 高度這些信息。這一部分的信息采集是為了在 Cell 復用的核心部分,Cell 的 Rect 尺寸與 tableView 尺寸計算邊界情況建立數據基礎。
3 復用 Cell 的核心處理階段
我們要關注三個存儲容器的變化情況:
- NSMutableDictionary 類型 _cachedCells:用來存儲當前屏幕上所有 Cell 與其對應的 indexPath。以鍵值對的關系進行存儲。
- NSMutableDictionary 類型 availableCells:當列表發生滑動的時候,部分 Cell 從屏幕移出,這個容器會對 _cachedCells 進行拷貝,然后將屏幕上此時的 Cell 全部去除。即最終取出所有退出屏幕的 Cell。
- NSMutableSet 類型 _reusableCells:用來收集曾經出現過此時未出現在屏幕上的 Cell。當再出滑入主屏幕時,則直接使用其中的對象根據 CGRectIntersectsRect Rect 碰撞試驗進行復用。
在整個核心復用階段,這三個容器都充當著很重要的角色。我們給出以下的場景實例,例如下圖的一個場景,圖 ① 為頁面剛剛載入的階段,圖 ② 為用戶向下滑動一個單元格時的狀態:
當到狀態 ② 的時候,我們發現 _reusableCells 容器中,已經出現了狀態 ① 中已經退出屏幕的 Cell 0。而當我們重新將 Cell 0 滑入界面的時候,在系統 addView 渲染階段,會直接將 _reusableCells 中的 Cell 0 立即取出進行渲染,從而代替創建新的實例再進行渲染,簡化了時間與性能上的開銷。
UITableView 的其他細節優化
復用容器數據類型 NSMutableSet
在三個重要的容器中,只有 _reusableCells 使用了 NSMutableSet。這是因為我們在每一次對于 _cachedCells 中的 Cell 進行遍歷并在屏幕上渲染時,都需要在 _reusableCells 進行一次掃描。而且當一個頁面反復的上下滑動時,_reusableCells 的檢索復雜度是相當龐大的。為了確保這一情況下滑動的流暢性,Apple 在設計時不得不將檢索復雜度最小化。并且這個復雜度要是非抖動的,不能給體驗造成太大的不穩定性。
高度緩存容器 _sections
在每次布局方法觸發階段,由于 Cell 的狀態發生了變化。在對 Cell 復用容器的修改之前,首先要做的一件事是以 Section 為單位對所有的 Cell 進行緩存高度。從這里可以看出 UITableView 設計師的細節。 Cell 的高度在 UITableView 中充當著十分重要的角色,一下列表是需要使用高度的方法:
- (CGFloat)_offsetForSection:(NSInteger)index:計算指定 Cell 的滑動偏移量。
- (CGRect)rectForSection:(NSInteger)section:返回某個 Section 的整體 Rect。
- (CGRect)rectForHeaderInSection:(NSInteger)section:返回某個 Header 的 Rect。
- (CGRect)rectForFooterInSection:(NSInteger)section:返回某個 Footer 的 Rect。
- (CGRect)rectForRowAtIndexPath:(NSIndexPath *)indexPath:返回某個 Cell 的 Rect。
- (NSArray *)indexPathsForRowsInRect:(CGRect)rect:返回 Rect 列表。
- (void)_setContentSize:根據高度計算 UITableView 中實際內容的 Size。 一次
下一個目標 研究 FDTemplateLayoutCell 的優化方案
參考
- Chameleon
- 《iOS 成長之路》
總結
以上是生活随笔為你收集整理的深入源码 UITableView 复用技术原理分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于Unity中的UGUI优化,你可能遇
- 下一篇: Java9新特性系列(模块化系统: Ji