簡介
在我們的日常開發(fā)中,絕大多數(shù)情況下只要詳細閱讀類頭文件里的注釋柬批,組合UIKit框架里的大量控件就能很好的滿足工作的需求赴魁。但僅僅會使用UIKit里的控件還遠遠不夠吝镣,假如現(xiàn)在產(chǎn)品需要一個類似 Excel 樣式的控件來呈現(xiàn)數(shù)據(jù),需要這個控件能上下左右滑動泉蝌,這時候你會發(fā)現(xiàn)UIKit里就沒有現(xiàn)成的控件可用了歇万。UITableView 可以看做一個只可以上下滾動的 Excel揩晴,所以我們的直覺是應(yīng)該仿寫 UITableView 來實現(xiàn)這個自定義的控件。這篇文章我將會通過開源項目 Chameleon 來分析UITableView的 hacking 源碼贪磺,閱讀完這篇文章后你將會了解 UITableView 的繪制過程和 UITableViewCell 的復(fù)用原理硫兰。 并且我會在下一篇文章中實現(xiàn)一個類似 Excel 的自定義控件。
Chameleon
Chameleon 是一個移植 iOS 的 UIKit 框架到 Mac OS X 下的開源項目寒锚。該項目的目的在于盡可能給出 UIKit 的可替代方案劫映,并且讓 Mac OS 的開發(fā)者盡可能的開發(fā)出類似 iOS 的 UI 界面。
UITableView的簡單使用
//創(chuàng)建UITableView對象刹前,并設(shè)置代代理和數(shù)據(jù)源為包含該視圖的視圖控制器
UITableView *tableView = [[UITableView alloc] initWithFrame:frame style:UITableViewStyleGrouped];
tableView.delegate = self;
tableView.dataSource = self;
[tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kReuseCellIdentifier];
[self.view addSubview:tableView];
//實現(xiàn)代理和數(shù)據(jù)源協(xié)議中的方法
#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;
}
創(chuàng)建UITableView實例對象
UITableView *tableView = [[UITableView alloc] initWithFrame:frame style:UITableViewStyleGrouped];
initWithFrame: style: 方法源碼如下:
- (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 后轧飞,至下一次數(shù)據(jù)源變化調(diào)用 reloadData 方法衅鹿,
//由于數(shù)據(jù)源沒有變化,section 相關(guān)的高度值是不會變化过咬,只需計算一次大渤,所以需要緩存起來。
_sections = [[NSMutableArray alloc] init];
//_reusableCells用于保存存在但未顯示在界面上的可復(fù)用的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;
}
我將需要關(guān)注的地方做了詳細的注釋掸绞,這里我們需要關(guān)注_cachedCells, _sections, _reusableCells 這三個變量的作用泵三。
設(shè)置數(shù)據(jù)源
tableView.dataSource = self;
下面是 dataSrouce 的 setter 方法源碼:
- (void)setDataSource:(id<UITableViewDataSource>)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 是用于記錄該數(shù)據(jù)源實現(xiàn)了哪些協(xié)議方法的結(jié)構(gòu)體,該結(jié)構(gòu)體源碼如下:
struct {
unsigned numberOfSectionsInTableView : 1;
unsigned titleForHeaderInSection : 1;
unsigned titleForFooterInSection : 1;
unsigned commitEditingStyle : 1;
unsigned canEditRowAtIndexPath : 1;
} _dataSourceHas;
記錄是否實現(xiàn)了某協(xié)議可以使用布爾值來表示衔掸,布爾變量占用的內(nèi)存大小一般為一個字節(jié)烫幕,即8比特。但該結(jié)構(gòu)體使用了 bitfields 用一個比特(0或1)來記錄是否實現(xiàn)了某協(xié)議敞映,大大縮小了占用的內(nèi)存较曼。
在設(shè)置好了數(shù)據(jù)源后需要打一個標(biāo)記,告訴NSRunLoop數(shù)據(jù)源已經(jīng)設(shè)置好了振愿,需要在下一次循環(huán)中使用數(shù)據(jù)源進行布局捷犹。下面看看 _setNeedReload 的源碼:
- (void)_setNeedsReload
{
_needsReload = YES;
[self setNeedsLayout];
}
在調(diào)用了 setNeedsLayout 方法后,NSRunloop 會在下一次循環(huán)中自動調(diào)用 layoutSubViews 方法冕末。
- 視圖的內(nèi)容需要重繪時可以調(diào)用 setNeedsDisplay 方法萍歉,該方法會設(shè)置該視圖的 displayIfNeeded 變量為 YES ,NSRunLoop 在下一次循環(huán)檢中測到該值為 YES 則會自動調(diào)用 drawRect 進行重繪档桃。
- 視圖的內(nèi)容沒有變化枪孩,但在父視圖中位置變化了可以調(diào)用 setNeedsLayout,該方法會設(shè)置該視圖的 layoutIfNeeded 變量為YES,NSRunLoop 在下一次循環(huán)檢中測到該值為 YES 則會自動調(diào)用 layoutSubViews 進行重繪。
- 更詳細的內(nèi)容可參考 When is layoutSubviews called?
設(shè)置代理
tableView.delegate = self;
下面是 delegate 的 setter 方法源碼:
- (void)setDelegate:(id<UITableViewDelegate>)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:)];
}
與設(shè)置數(shù)據(jù)源一樣蔑舞,這里使用了類似的結(jié)構(gòu)體來記錄代理實現(xiàn)了哪些協(xié)議方法拒担。
UITableView繪制
由于在設(shè)置數(shù)據(jù)源中調(diào)用了 setNeedsLayout 方法打上了需要布局的 flag,所以會在 1/60 秒(NSRunLoop的循環(huán)周期)后自動調(diào)用 layoutSubViews斗幼。layoutSubViews 的源碼如下:
- (void)layoutSubviews
{
//對子視圖進行布局澎蛛,該方法會在第一次設(shè)置數(shù)據(jù)源調(diào)用 setNeedsLayout 方法后自動調(diào)用。
//并且 UITableView 是繼承自 UIScrollview 蜕窿,當(dāng)滾動時也會觸發(fā)該方法的調(diào)用
_backgroundView.frame = self.bounds;
//在進行布局前必須確保 section 已經(jīng)緩存了所有高度相關(guān)的信息
[self _reloadDataIfNeeded];
//對 UITableView 的 section 進行布局谋逻,包含 section 的頭部,尾部桐经,每一行 Cell
[self _layoutTableView];
//對 UITableView 的頭視圖毁兆,尾視圖進行布局
[super layoutSubviews];
}
需要注意的是由于 UITableView 是繼承于 UIScrollView,所以在 UITableView 滾動時會自動調(diào)用該方法阴挣,詳細內(nèi)容可以參考 When is layoutSubviews called?
下面依次來看三個主要方法的實現(xiàn)气堕。
_reloadDataIfNeeded 的源碼如下:
- (void)_reloadDataIfNeeded
{
if (_needsReload) {
[self reloadData];
}
}
- (void)reloadData
{
//當(dāng)數(shù)據(jù)源更新后,需要將所有顯示的UITableViewCell和未顯示可復(fù)用的UITableViewCell全部從父視圖移除畔咧,
//重新創(chuàng)建
[[_cachedCells allValues] makeObjectsPerformSelector:@selector(removeFromSuperview)];
[_reusableCells makeObjectsPerformSelector:@selector(removeFromSuperview)];
[_reusableCells removeAllObjects];
[_cachedCells removeAllObjects];
_selectedRow = nil;
_highlightedRow = nil;
// 重新計算 section 相關(guān)的高度值茎芭,并緩存起來
[self _updateSectionsCache];
[self _setContentSize];
_needsReload = NO;
}
其中 _updateSectionsCashe 方法是最重要的,該方法在數(shù)據(jù)源更新后至下一次數(shù)據(jù)源更新期間只能調(diào)用一次誓沸,該方法的源碼如下:
- (void)_updateSectionsCache
{
//該逆向源碼只復(fù)用了 section 中的每個 UITableViewCell梅桩,并沒有復(fù)用每個 section 的頭視圖和尾視圖,
//UIKit肯定是實現(xiàn)了所有視圖的復(fù)用
// remove all previous section header/footer views
for (UITableViewSection *previousSectionRecord in _sections) {
[previousSectionRecord.headerView removeFromSuperview];
[previousSectionRecord.footerView removeFromSuperview];
}
// clear the previous cache
[_sections removeAllObjects];
//如果數(shù)據(jù)源為空拜隧,不做任何處理
if (_dataSource) {
// compute the heights/offsets of everything
const CGFloat defaultRowHeight = _rowHeight ?: _UITableViewDefaultRowHeight;
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;
// 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 的高度使用了數(shù)組指針來保存
CGFloat *rowHeights = malloc(numberOfRowsInSection * sizeof(CGFloat));
CGFloat totalRowsHeight = 0;
//每行 row 的高度通過數(shù)據(jù)源實現(xiàn)的協(xié)議方法 heightForRowAtIndexPath: 返回宿百,
//若數(shù)據(jù)源沒有實現(xiàn)該協(xié)議方法則使用默認的高度
for (NSInteger row=0; row<numberOfRowsInSection; row++) {
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];
}
}
}
我在需要注意的地方加了注釋,上面方法主要是記錄每個 Cell 的高度和整個 section 的高度洪添,并把結(jié)果同過 UITableViewSection 對象緩存起來垦页。
_layoutTableView 的源碼實現(xiàn)如下:
- (void)_layoutTableView
{
//這里實現(xiàn)了 UITableViewCell 的復(fù)用
const CGSize boundsSize = self.bounds.size;
const CGFloat contentOffset = self.contentOffset.y;
//由于 UITableView 繼承于 UIScrollview,所以通過滾動偏移量得到當(dāng)前可視的 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 用于記錄當(dāng)前正在顯示但在滾動后不再顯示的 UITableViewCell(該 Cell 可以復(fù)用)
//在滾動后將該字典中的所有數(shù)據(jù)都添加到 _reusableCells 中,
//記錄下所有當(dāng)前在可視但由于滾動而變得不再可視的 Cell 的引用
NSMutableDictionary *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;
//CGRectIntersectsRect 方法用于判斷兩個 rect 是否有相交痊焊,只處理在當(dāng)前可視 bounds 內(nèi)的 section
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++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
CGRect rowRect = [self rectForRowAtIndexPath:indexPath];
//只處理在當(dāng)前可視 bounds 內(nèi)的 row
if (CGRectIntersectsRect(rowRect,visibleBounds) && rowRect.size.height > 0) {
//在滾動時,如果向上滾動律胀,除去頂部要隱藏的 Cell 和底部要顯示的 Cell宋光,中部的 Cell 都可以
//根據(jù) indexPath 直接獲取
UITableViewCell *cell = [availableCells objectForKey:indexPath] ?: [self.dataSource tableView:self cellForRowAtIndexPath:indexPath];
if (cell) {
[_cachedCells setObject:cell forKey:indexPath];
//將當(dāng)前仍留在可視區(qū)域的 Cell 從 availableCells 中移除,
//availableCells 中剩下的即為頂部已經(jīng)隱藏的 Cell
//后面會將該 Cell 加入 _reusableCells 中以便下次取出進行復(fù)用炭菌。
[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 中,以便下次取出復(fù)用
for (UITableViewCell *cell in [availableCells allValues]) {
if (cell.reuseIdentifier) {
[_reusableCells addObject:cell];
} else {
[cell removeFromSuperview];
}
}
//把仍在可視區(qū)域的 Cell(但不應(yīng)該在父視圖上顯示) 但已經(jīng)被回收至可復(fù)用的 _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;
}
}
關(guān)于 UIView 的 frame 和bounds 的區(qū)別可以參考 What's the difference between the frame and the bounds?
這里使用了三個容器 _cachedCells, availableCells, _reusableCells 完成了 Cell 的復(fù)用逛漫,這是 UITableView 最核心的地方黑低。
下面一起看看三個容器在創(chuàng)建到滾動整個過程中所包含的元素的變化情況。
在第一次設(shè)置了數(shù)據(jù)源調(diào)用該方法時,三個容器的內(nèi)容都為空克握,在調(diào)用完該方法后 _cachedCells 包含了當(dāng)前所有可視 Cell 與其對應(yīng)的indexPath 的鍵值對蕾管,availableCells 與 _reusableCells 仍然為空。只有在滾動起來后 _reusableCells 中才會出現(xiàn)多余的未顯示可復(fù)用的 Cell菩暗。
-
剛創(chuàng)建 UITableView 時的狀態(tài)如下圖(紅色為屏幕內(nèi)容即可視區(qū)域掰曾,藍色為超出屏幕的內(nèi)容,即不可視區(qū)域):
如圖停团,當(dāng)前 _cachedCells 的元素為當(dāng)前可視的所有 Cell 與其對應(yīng)的 indexPath 的鍵值對旷坦。
-
向上滾動一個 Cell 的過程中,由于 availableCells 為 _cachedCells 的拷貝佑稠,所以可根據(jù) indexPath 直接取到對應(yīng)的 Cell秒梅,這時從底部滾上來的第7行,由于之前的 _reusableCells 為空舌胶,所以該 Cell 是直接創(chuàng)建的而并非復(fù)用的捆蜀,由于頂部 Cell 滾動出了可視區(qū)域,所以被加入了 _reusableCells 中以便后續(xù)滾動復(fù)用幔嫂。滾動完一行后的狀態(tài)變?yōu)榱?_cachedCells 包含第 2 行到第 7 行 Cell 的引用辆它,_reusableCells 包含第一行 之前滾動出可視區(qū)域的第一行 Cell 的引用。
-
當(dāng)向上滾動兩個 Cell 的過程中履恩,同理第 3 行到第 7 行的 Cell 可以通過對應(yīng)的 indexPath 從 _cachedCells 中獲取锰茉。這時 _reusableCells 中正好有一個可以復(fù)用的 Cell 用來從底部滾動上來的第 8 行。滾動出頂部的第 2 行 Cell 被加入 _reusableCells 中似袁。
總結(jié)
到此你已經(jīng)了解了 UITableView 的 Cell 的復(fù)用原理洞辣,可以根據(jù)需要定制出更復(fù)雜的控件。
歡迎關(guān)注我的簡書昙衅,我會定期做一些技術(shù)分享:)