簡介
在我們的日常開發中,絕大多數情況下只要詳細閱讀類標頭檔案裡的註釋,組合UIKit框架裡的大量控制元件就能很好的滿足工作的需求。但僅僅會使用UIKit裡的控制元件還遠遠不夠,假如現在產品需要一個類似 Excel 樣式的控制元件來呈現資料,需要這個控制元件能上下左右滑動,這時候你會發現UIKit裡就沒有現成的控制元件可用了。UITableView 可以看做一個只可以上下滾動的 Excel,所以我們的直覺是應該仿寫 UITableView 來實現這個自定義的控制元件。這篇文章我將會通過開源專案 Chameleon 來分析UITableView的 hacking 原始碼,閱讀完這篇文章後你將會了解 UITableView 的繪製過程和 UITableViewCell 的複用原理。 並且我會在下一篇文章中實現一個類似 Excel 的自定義控制元件。
Chameleon
Chameleon 是一個移植 iOS 的 UIKit 框架到 Mac OS X 下的開源專案。該專案的目的在於儘可能給出 UIKit 的可替代方案,並且讓 Mac OS 的開發者儘可能的開發出類似 iOS 的 UI 介面。
UITableView的簡單使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
//建立UITableView物件,並設定代代理和資料來源為包含該檢視的檢視控制器 UITableView *tableView = [[UITableView alloc] initWithFrame:frame style:UITableViewStyleGrouped]; tableView.delegate = self; tableView.dataSource = self; [tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kReuseCellIdentifier]; [self.view addSubview:tableView]; //實現代理和資料來源協議中的方法 #pragma mark - UITableViewDelegate - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return kDefaultCellHeight; } #pragma mark - UITableViewDataSource - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kReuseCellIdentifier]; return cell; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.dataArray.count; } |
建立UITableView例項物件
1 |
UITableView *tableView = [[UITableView alloc] initWithFrame:frame style:UITableViewStyleGrouped]; |
initWithFrame: style: 方法原始碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
- (id)initWithFrame:(CGRect)frame style:(UITableViewStyle)theStyle { if ((self=[super initWithFrame:frame])) { _style = theStyle; //_cachedCells 用於儲存正在顯示的Cell物件的引用 _cachedCells = [[NSMutableDictionary alloc] init]; //在計算完每個 section 包含的 section 頭部,尾部檢視的高度,和包含的每個 row 的整體高度後, //使用 UITableViewSection 物件對這些高度值進行儲存,並將該 UITableViewSection 物件的引用 //儲存到 _sections中。在指定完 dataSource 後,至下一次資料來源變化呼叫 reloadData 方法, //由於資料來源沒有變化,section 相關的高度值是不會變化,只需計算一次,所以需要快取起來。 _sections = [[NSMutableArray alloc] init]; //_reusableCells用於儲存存在但未顯示在介面上的可複用的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]; } [self _setNeedsReload]; } return self; } |
我將需要關注的地方做了詳細的註釋,這裡我們需要關注_cachedCells, _sections, _reusableCells 這三個變數的作用。
設定資料來源
1 |
tableView.dataSource = self; |
下面是 dataSrouce 的 setter 方法原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
- (void)setDataSource:(id)newSource { _dataSource = newSource; _dataSourceHas.numberOfSectionsInTableView = [_dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)]; _dataSourceHas.titleForHeaderInSection = [_dataSource respondsToSelector:@selector(tableView:titleForHeaderInSection:)]; _dataSourceHas.titleForFooterInSection = [_dataSource respondsToSelector:@selector(tableView:titleForFooterInSection:)]; _dataSourceHas.commitEditingStyle = [_dataSource respondsToSelector:@selector(tableView:commitEditingStyle:forRowAtIndexPath:)]; _dataSourceHas.canEditRowAtIndexPath = [_dataSource respondsToSelector:@selector(tableView:canEditRowAtIndexPath:)]; [self _setNeedsReload]; } |
_dataSourceHas 是用於記錄該資料來源實現了哪些協議方法的結構體,該結構體原始碼如下:
1 2 3 4 5 6 7 |
struct { unsigned numberOfSectionsInTableView : 1; unsigned titleForHeaderInSection : 1; unsigned titleForFooterInSection : 1; unsigned commitEditingStyle : 1; unsigned canEditRowAtIndexPath : 1; } _dataSourceHas; |
記錄是否實現了某協議可以使用布林值來表示,布林變數佔用的記憶體大小一般為一個位元組,即8位元。但該結構體使用了 bitfields 用一個位元(0或1)來記錄是否實現了某協議,大大縮小了佔用的記憶體。
在設定好了資料來源後需要打一個標記,告訴NSRunLoop資料來源已經設定好了,需要在下一次迴圈中使用資料來源進行佈局。下面看看 _setNeedReload 的原始碼:
1 2 3 4 5 |
- (void)_setNeedsReload { _needsReload = YES; [self setNeedsLayout]; } |
在呼叫了 setNeedsLayout 方法後,NSRunloop 會在下一次迴圈中自動呼叫 layoutSubViews 方法。
- 檢視的內容需要重繪時可以呼叫 setNeedsDisplay 方法,該方法會設定該檢視的 displayIfNeeded 變數為 YES ,NSRunLoop 在下一次迴圈檢中測到該值為 YES 則會自動呼叫 drawRect 進行重繪。
- 檢視的內容沒有變化,但在父檢視中位置變化了可以呼叫 setNeedsLayout,該方法會設定該檢視的 layoutIfNeeded 變數為YES,NSRunLoop 在下一次迴圈檢中測到該值為 YES 則會自動呼叫 layoutSubViews 進行重繪。
- 更詳細的內容可參考 When is layoutSubviews called?
設定代理
1 |
tableView.delegate = self; |
下面是 delegate 的 setter 方法原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
- (void)setDelegate:(id)newDelegate { [super setDelegate:newDelegate]; _delegateHas.heightForRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)]; _delegateHas.heightForHeaderInSection = [newDelegate respondsToSelector:@selector(tableView:heightForHeaderInSection:)]; _delegateHas.heightForFooterInSection = [newDelegate respondsToSelector:@selector(tableView:heightForFooterInSection:)]; _delegateHas.viewForHeaderInSection = [newDelegate respondsToSelector:@selector(tableView:viewForHeaderInSection:)]; _delegateHas.viewForFooterInSection = [newDelegate respondsToSelector:@selector(tableView:viewForFooterInSection:)]; _delegateHas.willSelectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:willSelectRowAtIndexPath:)]; _delegateHas.didSelectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)]; _delegateHas.willDeselectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:willDeselectRowAtIndexPath:)]; _delegateHas.didDeselectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:didDeselectRowAtIndexPath:)]; _delegateHas.willBeginEditingRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:willBeginEditingRowAtIndexPath:)]; _delegateHas.didEndEditingRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:didEndEditingRowAtIndexPath:)]; _delegateHas.titleForDeleteConfirmationButtonForRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:titleForDeleteConfirmationButtonForRowAtIndexPath:)]; } |
與設定資料來源一樣,這裡使用了類似的結構體來記錄代理實現了哪些協議方法。
UITableView繪製
由於在設定資料來源中呼叫了 setNeedsLayout 方法打上了需要佈局的 flag,所以會在 1/60 秒(NSRunLoop的迴圈週期)後自動呼叫 layoutSubViews。layoutSubViews 的原始碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
- (void)layoutSubviews { //對子檢視進行佈局,該方法會在第一次設定資料來源呼叫 setNeedsLayout 方法後自動呼叫。 //並且 UITableView 是繼承自 UIScrollview ,當滾動時也會觸發該方法的呼叫 _backgroundView.frame = self.bounds; //在進行佈局前必須確保 section 已經快取了所有高度相關的資訊 [self _reloadDataIfNeeded]; //對 UITableView 的 section 進行佈局,包含 section 的頭部,尾部,每一行 Cell [self _layoutTableView]; //對 UITableView 的頭檢視,尾檢視進行佈局 [super layoutSubviews]; } |
需要注意的是由於 UITableView 是繼承於 UIScrollView,所以在 UITableView 滾動時會自動呼叫該方法,詳細內容可以參考 When is layoutSubviews called?
下面依次來看三個主要方法的實現。
_reloadDataIfNeeded 的原始碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
- (void)_reloadDataIfNeeded { if (_needsReload) { [self reloadData]; } } - (void)reloadData { //當資料來源更新後,需要將所有顯示的UITableViewCell和未顯示可複用的UITableViewCell全部從父檢視移除, //重新建立 [[_cachedCells allValues] makeObjectsPerformSelector:@selector(removeFromSuperview)]; [_reusableCells makeObjectsPerformSelector:@selector(removeFromSuperview)]; [_reusableCells removeAllObjects]; [_cachedCells removeAllObjects]; _selectedRow = nil; _highlightedRow = nil; // 重新計算 section 相關的高度值,並快取起來 [self _updateSectionsCache]; [self _setContentSize]; _needsReload = NO; } |
其中 _updateSectionsCashe 方法是最重要的,該方法在資料來源更新後至下一次資料來源更新期間只能呼叫一次,該方法的原始碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
- (void)_updateSectionsCache { //該逆向原始碼只複用了 section 中的每個 UITableViewCell,並沒有複用每個 section 的頭檢視和尾檢視, //UIKit肯定是實現了所有檢視的複用 // remove all previous section header/footer views for (UITableViewSection *previousSectionRecord in _sections) { [previousSectionRecord.headerView removeFromSuperview]; [previousSectionRecord.footerView removeFromSuperview]; } // clear the previous cache [_sections removeAllObjects]; //如果資料來源為空,不做任何處理 if (_dataSource) { // compute the heights/offsets of everything const CGFloat defaultRowHeight = _rowHeight ?: _UITableViewDefaultRowHeight; const NSInteger numberOfSections = [self numberOfSections]; for (NSInteger section=0; section 0 && _delegateHas.viewForHeaderInSection)? [self.delegate tableView:self viewForHeaderInSection:section] : nil; sectionRecord.footerView = (sectionRecord.footerHeight > 0 && _delegateHas.viewForFooterInSection)? [self.delegate tableView:self viewForFooterInSection:section] : nil; // make a default section header view if there's a title for it and no overriding view if (!sectionRecord.headerView && sectionRecord.headerHeight > 0 && sectionRecord.headerTitle) { sectionRecord.headerView = [UITableViewSectionLabel sectionLabelWithTitle:sectionRecord.headerTitle]; } // make a default section footer view if there's a title for it and no overriding view 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; } //section 中每個 row 的高度使用了陣列指標來儲存 CGFloat *rowHeights = malloc(numberOfRowsInSection * sizeof(CGFloat)); CGFloat totalRowsHeight = 0; //每行 row 的高度通過資料來源實現的協議方法 heightForRowAtIndexPath: 返回, //若資料來源沒有實現該協議方法則使用預設的高度 for (NSInteger row=0; row |
我在需要注意的地方加了註釋,上面方法主要是記錄每個 Cell 的高度和整個 section 的高度,並把結果同過 UITableViewSection 物件快取起來。
_layoutTableView 的原始碼實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
- (void)_layoutTableView { //這裡實現了 UITableViewCell 的複用 const CGSize boundsSize = self.bounds.size; const CGFloat contentOffset = self.contentOffset.y; //由於 UITableView 繼承於 UIScrollview,所以通過滾動偏移量得到當前可視的 bounds const CGRect visibleBounds = CGRectMake(0,contentOffset,boundsSize.width,boundsSize.height); CGFloat tableHeight = 0; //若有頭部檢視,則計算頭部檢視在父檢視中的 frame if (_tableHeaderView) { CGRect tableHeaderFrame = _tableHeaderView.frame; tableHeaderFrame.origin = CGPointZero; tableHeaderFrame.size.width = boundsSize.width; _tableHeaderView.frame = tableHeaderFrame; tableHeight += tableHeaderFrame.size.height; } //_cashedCells 用於記錄正在顯示的 UITableViewCell 的引用 //avaliableCells 用於記錄當前正在顯示但在滾動後不再顯示的 UITableViewCell(該 Cell 可以複用) //在滾動後將該字典中的所有資料都新增到 _reusableCells 中, //記錄下所有當前在可視但由於滾動而變得不再可視的 Cell 的引用 NSMutableDictionary *availableCells = [_cachedCells mutableCopy]; const NSInteger numberOfSections = [_sections count]; [_cachedCells removeAllObjects]; for (NSInteger section=0; section 0) { //在滾動時,如果向上滾動,除去頂部要隱藏的 Cell 和底部要顯示的 Cell,中部的 Cell 都可以 //根據 indexPath 直接獲取 UITableViewCell *cell = [availableCells objectForKey:indexPath] ?: [self.dataSource tableView:self cellForRowAtIndexPath:indexPath]; if (cell) { [_cachedCells setObject:cell forKey:indexPath]; //將當前仍留在可視區域的 Cell 從 availableCells 中移除, //availableCells 中剩下的即為頂部已經隱藏的 Cell //後面會將該 Cell 加入 _reusableCells 中以便下次取出進行復用。 [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]; } } } } } //把所有因滾動而不再可視的 Cell 從父檢視移除並加入 _reusableCells 中,以便下次取出複用 for (UITableViewCell *cell in [availableCells allValues]) { if (cell.reuseIdentifier) { [_reusableCells addObject:cell]; } else { [cell removeFromSuperview]; } } //把仍在可視區域的 Cell(但不應該在父檢視上顯示) 但已經被回收至可複用的 _reusableCells 中的 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; } } |
關於 UIView 的 frame 和bounds 的區別可以參考 What’s the difference between the frame and the bounds?
這裡使用了三個容器 _cachedCells, availableCells, _reusableCells 完成了 Cell 的複用,這是 UITableView 最核心的地方。
下面一起看看三個容器在建立到滾動整個過程中所包含的元素的變化情況。
在第一次設定了資料來源呼叫該方法時,三個容器的內容都為空,在呼叫完該方法後 _cachedCells 包含了當前所有可視 Cell 與其對應的indexPath 的鍵值對,availableCells 與 _reusableCells 仍然為空。只有在滾動起來後 _reusableCells 中才會出現多餘的未顯示可複用的 Cell。
- 剛建立 UITableView 時的狀態如下圖(紅色為螢幕內容即可視區域,藍色為超出螢幕的內容,即不可視區域):
如圖,當前 _cachedCells 的元素為當前可視的所有 Cell 與其對應的 indexPath 的鍵值對。
- 向上滾動一個 Cell 的過程中,由於 availableCells 為 _cachedCells 的拷貝,所以可根據 indexPath 直接取到對應的 Cell,這時從底部滾上來的第7行,由於之前的 _reusableCells 為空,所以該 Cell 是直接建立的而並非複用的,由於頂部 Cell 滾動出了可視區域,所以被加入了 _reusableCells 中以便後續滾動複用。滾動完一行後的狀態變為了 _cachedCells 包含第 2 行到第 7 行 Cell 的引用,_reusableCells 包含第一行 之前滾動出可視區域的第一行 Cell 的引用。
- 當向上滾動兩個 Cell 的過程中,同理第 3 行到第 7 行的 Cell 可以通過對應的 indexPath 從 _cachedCells 中獲取。這時 _reusableCells 中正好有一個可以複用的 Cell 用來從底部滾動上來的第 8 行。滾動出頂部的第 2 行 Cell 被加入 _reusableCells 中。
總結
到此你已經瞭解了 UITableView 的 Cell 的複用原理,可以根據需要定製出更復雜的控制元件。