阅读

让我们构造UITableView

译者按: 在mikeash看到一篇关于构造UITableView的文章,写的比较底层,为方便深入学习UITableView,特翻译过来一起学习,英文水平有限,请多多指正,共同提高。

原文作者Matthew Elton,

原文地址:https://mikeash.com/pyblog/friday-qa-2013-02-22-lets-build-uitableview.html,

源代码地址:https://github.com/Obliquely/Let-s-Build-UITableView

UITableVIew是一个非常强大和充满特色的类,但其内在工作方式神秘。大部分时间使用这个类是简单而明确的:按照文档规范,开发者可以在行数很多的情况下以较少的内存获得Table滚动的响应。但在复杂的情况下,例如一个巨大表格的每一行都不同的同时高度还在变化的情况下,这就需要开发者去理解这个类是到底是如何工作的。

在此文中,我将实现一个基本的表格类,这将展示这个类是如何不可思议的工作以及展示UITableView的data source和delegate是在什么时间做什么事情的。

实现策略

UITabelView是UIScrollView的一个子类,由于继承此父类,实现一个简单的table view只需要很少的工作量。但在深入研究代码前,需要考虑两个关键任务用以保证高性能和低内存消耗。

一、tableview需要一个可重用View的池子去展现这个table里的各个行,这是为什么呢?

想想看,假如一个表格有1000行,对于用户来说,看起来像所有这1000个views都一个接着一个整齐的堆放着,尽管构造这些views很快,而且现代电子设备拥有大量内存空间,但这个table view不得不构造和存储1000个views,对性能会有严重影响,也就意味着,当这个table第一次展现时会有令人不快的延迟,这并不好。

幸运的是,table view不需要采用这种方法,它只需要表现出它好像拥有1000个整齐堆放的views。实际上,这个tableview仅需要在当前所展现出来的那些行的views是真实的views就可以了,而通常这只是一个相当小的数字(屏幕就那么点大),无论如何会比1000要小的多且可靠的多,然后它需要确保根据当前显示出来的行来更新内容。并从池子里回收views,而不是根据需要来制造新的views,确保在老机器上也能迅速和顺滑的滚动。

二、tableview需要知道起始位置和它每一行的高度,而且,它需要在进行所有布局之前得到这些信息,这是为什么呢?

首先,它需要知道高度以便告知scroll view该内容的尺寸,从而确保scroll bars是正确的尺寸,这样当用户滑动tableview到底部会体验到令人愉悦的弹性效果等。

第二,每当scroll view滚动时,table view需要解决怎样复位可重用的views以及是否更新它们的内容。

可重用池是非常容易构建的,所以我们先实现这个,对于各个行相互协调,以及它们的偏移量和内容,有点小麻烦,我们在第二部分分阶段来实现;

Views的可重用池

UITableView有可重用池,Apple叫它队列(queue),每个view表示table的一个行。一般每个行都相似但有时也会有不同类型的行,所以在重用池工作时UITableView使用它的data source去指定重用标识符。重用标识符是一个由UITableView的一个方法dequeueReusableCellWithIdentifier:来传递的NSString。dequeueReusableCellWithIdentifier方法让UITableView返回一个view,一个新建的UITableView它的池子是空的,此时这个方法返回nil。一旦UITableView运行,就可能在池子里拥有views,如果产生了views而且他们的重用标识符与dequeueReusableCellWithIdentifier指定的一致,这些views就返回。

如果你已经用过UITableView,你对使用dequeueReusableCellWithIdentifier的标准模式会比较熟悉,在data source,你实现tableView:cellForRowAtIndexPath:方法去返回特定的行。在开始此方法前,你会从池子里抓取一个view或者制造一个新的。不管怎样你都会为该行的view填充数据。典型的代码像下面这样:

- (UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath
{
UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier: @"standardRow"];
if (!cell)
{
cell = [[UITableViewCell alloc] initWithStyle: UITableViewCellStyleDefault reuseIdentifier: @"standardRow"];
[cell autorelease];
}

[self populateCell: cell forIndexPath: indexPath];
return cell;
}

ok,让我们来实现dequeueReusableCellWithIdentifier这个方法

- (PGTableViewCell*) dequeueReusableCellWithIdentifier: (NSString*) reuseIdentifier
{
PGTableViewCell* poolCell = nil;

for (PGTableViewCell* tableViewCell in [self reusePool])
{
if ([[tableViewCell reuseIdentifier] isEqualToString: reuseIdentifier])
{
poolCell = tableViewCell;
break;
}
}

if (poolCell)
{
[poolCell retain];
[[self reusePool] removeObject: poolCell];
[poolCell autorelease];
}

return poolCell;
}

在以上实现过程中,重用池属性是一个NSMutableArray,PGTableViewCell是UIView的一个有额外属性的简单子类,NSString类型的reuseIdentifier叫重用标识符。真正的UITableViewCell拥有很多额外功能,但这里这个属性是我们的基本实现过程中所必须的。
这个方法假定,在可重用池里,任何view都能被使用,即它不是被用来显示一个可见行。当然,为完成其工作,table view还需要确定相关的views已经被添加进去,换言之,它需要解决的问题是当一个行已经移动出屏幕的时候,要将此行加入到重用池中去。

收集高度和垂直偏移数据

UITableVIew能很自然的处理固定的和变化的行高,我们的基本实现过程也一样,如果delegate响应tableView:heightForRowAtIndexPath:方法,则UITableVIew将从这个delegate取得每个行高。通过使用data source里的tableView:numberOfRowsInSection:方法(必须实现的方法)和可选方法numberOfSectionsInTableView:方法(如果没有设置默认是1个section)来获得行数。

在我们的实现过程,我们将简化一些,table将没有任何sections,所以只需要行数。我们需要data source 去实现tableView:numberOfRowsInSection方法。依照苹果的,我们也提供可选的pgTableView:heightForRow:方法作为delegate 协议。

- (void) generateHeightAndOffsetData
{
CGFloat currentOffsetY = 0.0;

BOOL checkHeightForEachRow = [[self delegate] respondsToSelector: @selector(pgTableView:heightForRow:)];

NSMutableArray* newRowRecords = [NSMutableArray array];

NSInteger numberOfRows = [[self dataSource] numberOfRowsInPgTableView: self];

for (NSInteger row = 0; row < numberOfRows; row++)
{
PGRowRecord* rowRecord = [[PGRowRecord alloc] init];

CGFloat rowHeight = checkHeightForEachRow ? [[self delegate] pgTableView: self heightForRow: row] : [self rowHeight];

[rowRecord setHeight: rowHeight + _pgRowMargin];
[rowRecord setStartPositionY: currentOffsetY + _pgRowMargin];

[newRowRecords insertObject: rowRecord atIndex: row];
[rowRecord release];

currentOffsetY = currentOffsetY + rowHeight + _pgRowMargin;
}

[self setRowRecords: newRowRecords];

[self setContentSize: CGSizeMake([self bounds].size.width, currentOffsetY)];
}

该代码创建了一个包含PGRowRecord实例变量的数组,用于我们执行布局工作。PGRowRecord记录了一个行的开始位置,行高,以及后面会提到的表示行是否可见的pointer,在generateHeightAndOffsetData方法里并不知道行是否可见,所以并没设置这个pointer。
就像代码显示的那样,我们需要检查delegate是否提供了高度信息。如果是,我们每一行请求一次。

可以说,这里有余地来获取更高的效率的,例如从当前起始位置减去下一个起始位置来获取行高(和一些最后起始位置的记录,也就是说前后行的开始位置),此外,在行高固定的情况下,我们完全可以通过存储开始位置和高度,而只是在需要的时候计算他们,不过看起来我们还是需要数组,因为我们需要跟踪给定的行当前是否可见,所以我们不采取上述方法。

布局行

在收集了起始位置和高度以后,布局views的工作就简单了。tableview获取它的contentOffset(一个UIScrollView的属性,用以表明view的可视部分的起始位置)然后计算出需要显示的第一行,逐次逐行的直到可见部分被填满。

唯一一个复杂的地方是需要保持小心的去跟踪哪些行已经显示过,这样tableview能检查之前显示的行是否已经过去,如果那样的话,就将把它们放到池子以便重用。因此,returnNonVisibleRowsToTHePool:方法将在需要的时候工作。

- (void) layoutTableRows
{
CGFloat currentStartY = [self contentOffset].y;
CGFloat currentEndY = currentStartY + [self frame].size.height;

NSInteger rowToDisplay = [self findRowForOffsetY: currentStartY inRange: NSMakeRange(0, [[self rowRecords] count])];

NSMutableIndexSet* newVisibleRows = [[NSMutableIndexSet alloc] init];

CGFloat yOrigin;
CGFloat rowHeight;
do
{
[newVisibleRows addIndex: rowToDisplay];

yOrigin = [self startPositionYForRow: rowToDisplay];
rowHeight = [self heightForRow: rowToDisplay];

PGTableViewCell* cell = [self cachedCellForRow: rowToDisplay];

if (!cell)
{
cell = [[self dataSource] pgTableView: self cellForRow: rowToDisplay];
[self setCachedCell: cell forRow: rowToDisplay];

[cell setFrame: CGRectMake(0.0, yOrigin, [self bounds].size.width, rowHeight - _pgRowMargin)];
[self addSubview: cell];
}

rowToDisplay++;
}
while (yOrigin + rowHeight < currentEndY && rowToDisplay < [[self rowRecords] count]);

[self returnNonVisibleRowsToThePool: newVisibleRows];

[newVisibleRows release];
}

这个方法会调用很多次,每次你滚动table或着说每次系统这么做的时候,该方法都需要被调用,确保它是通过覆盖父类实现。

setContentOffset方法如下

- (void) setContentOffset:(CGPoint)contentOffset
{
[super setContentOffset: contentOffset];
[self layoutTableRows];
}

如果你运行这段代码,你可以放个NSLog在这,以便感受访问的频率,这是能保证layoutTableRows快速运行的。

而真实降低layoutTableRows速度,导致效率变低的一个方法是findRowForOffsetY:inRange方法,所以需要在这里进行一些努力。由于rowRecords数组已经分类过,我们可以利用NSArray里一个方法:indexOfObject:inSortedRange:options:usingComparator:的优势,这个方法对第一行进行二进制搜索,以便获取UIScrollView当前的垂直偏移。对于一个6000行左右的table,这个方法比从行列表开始处启动快100倍,也就是说,在做一些测量后就会明显发现,即使不是最佳的迭代法,在大部分时间里都是足够快的,这里说的足够快的意思是对于至少10000行的tables,这样做的效率也没有明显影响用户体验。

- (NSInteger) findRowForOffsetY: (CGFloat) yPosition inRange: (NSRange) range
{
if ([[self rowRecords] count] == 0) return 0;

PGRowRecord* rowRecord = [[PGRowRecord alloc] init];
[rowRecord setStartPositionY: yPosition];

NSInteger returnValue = [[self rowRecords] indexOfObject: rowRecord
inSortedRange: NSMakeRange(0, [[self rowRecords] count])
options: NSBinarySearchingInsertionIndex
usingComparator: ^NSComparisonResult(PGRowRecord* rowRecord1, PGRowRecord* rowRecord2){
if ([rowRecord1 startPositionY] < [rowRecord2 startPositionY])
return NSOrderedAscending;
return NSOrderedDescending;
}];
[rowRecord release];
if (returnValue == 0) return 0;
return returnValue - 1;
}

layoutTableRows使用的最后一个方法是returnNonVisibleRowsToThePool:它使用由NSMutableIndexSet类提供的一些简便方法,对于所有不可见的行,它将清除指向row的PGRowRecord实例里view的pointer,并从其superview删除视图,然后将它添加到池子里。

- (void) returnNonVisibleRowsToThePool: (NSMutableIndexSet*) currentVisibleRows
{
[[self visibleRows] removeIndexes: currentVisibleRows];
[[self visibleRows] enumerateIndexesUsingBlock:^(NSUInteger row, BOOL *stop)
{
PGTableViewCell* tableViewCell = [self cachedCellForRow: row];
if (tableViewCell)
{
[[self reusePool] addObject: tableViewCell];
[tableViewCell removeFromSuperview];
[self setCachedCell: nil forRow: row];
}
}];
[self setVisibleRows: currentVisibleRows];
}

基本上完成了
以上是所有困难的工作。reloadData方法只用我们已经写过的代码。由于generateHeightAndOffsetData方法将丢弃当前可视单元的记录,reloadData方法务必先删除当前所有可视视图。


- (void) reloadData
{
[self returnNonVisibleRowsToThePool: nil];
[self generateHeightAndOffsetData];
[self layoutTableRows];
}

剩下的只是内务管理,比如设置data source 以及delegate protocols,提供一些便利的方法来访问我们的PGRowRecords数组。完整的源代码,附带一个红利,就是row:changedHeight:方法,这个方法允许连续变化高度而不是强制代理来提供每一行的新高度。
源代码包括一个小测试app,这样你可以看到PGTableView的工作,并展示了本文的文本:代码,标题和文本。这个测试程序允许你关闭重用池,这样你可以看到性能的差异,并允许你测量findRowForOffset:inRange的两个变量。

可以在https://github.com/Obliquely/Let-s-Build-UITableView得到源代码,包括测试程序,这个代码是用于学习,并不是产品测试,你可以随意使用任何代码。

总结

这个练习揭示的一点是为什么当你访问reloadData方法时,UITableView要求获取每一行的行高,如果当tables有许多行,以及计算行高的成本较高时,这种要求就可能是一个负担。在这种情况下,当你或者table view访问reloadData时,通过缓存行高来避免重新计算就变的非常有意义,比如增加额外的一行或者其中一行的高度发生了变化。另外,如果你的表格需要处理方向变化然后需要行高的调整,你可以在后台计算你所不在的方位上的高度,这样如果当变化来的时候,这个工作已经完成了。

考虑到我们知道UITableVIew必须保持它自己的行高缓存,这个缓存工作可能需要做两次,也许有些烦人。毕竟,考虑到我们已经发现这里似乎任何实现,包括实现插入,删除以及移动方法,而不需要在每一行引发tableView:heightForRowAtIndexPath:方法,看得出对于苹果来说也许实现rowAtIndexPath:changedHeight:方法来处理延伸或缩短行是非常非常的容易。具备完全特点的UITableView仍然是一个强大的类,所以也许这个小儿科的介绍只是有点吹毛求疵了。


鼓励一下

如果觉得我的文章对您有用,请土豪扫右侧二维码打赏2块钱,帮我买杯咖啡,您的支持将鼓励我继续创作!”


关于Kovli Studio

Kovli Studio是本人全栈开发历程中部分作品的展示平台。作品从衣食住行娱乐等各角度为用户提供实用、高品质、高颜值、高性价比的品质生活方案,欢迎使用。