简介
在我们的日常开发中,绝大多数情况下只要详细阅读类头文件里的注释,组合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的简单使用
//创建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实例对象
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 后,至下一次数据源变化调用 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 这三个变量的作用。
设置数据源
tableView.dataSource = self;
下面是 dataSrouce 的 setter 方法源码:
- (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 是用于记录该数据源实现了哪些协议方法的结构体,该结构体源码如下:
struct {
unsigned numberOfSectionsInTableView : 1;
unsigned titleForHeaderInSection : 1;
unsigned titleForFooterInSection : 1;
unsigned commitEditingStyle : 1;
unsigned canEditRowAtIndexPath : 1;
} _dataSourceHas;
记录是否实现了某协议可以使用布尔值来表示,布尔变量占用的内存大小一般为一个字节,即8比特。但该结构体使用了 bitfields 用一个比特(0或1)来记录是否实现了某协议,大大缩小了占用的内存。
在设置好了数据源后需要打一个标记,告诉NSRunLoop数据源已经设置好了,需要在下一次循环中使用数据源进行布局。下面看看 _setNeedReload 的源码:
- (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?
设置代理
tableView.delegate = self;
下面是 delegate 的 setter 方法源码:
- (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 的源码如下:
- (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 的源码如下:
- (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 方法是最重要的,该方法在数据源更新后至下一次数据源更新期间只能调用一次,该方法的源码如下:
- (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 的源码实现如下:
- (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 的复用原理,可以根据需要定制出更复杂的控件。
欢迎关注我的,我会定期做一些技术分享:)