原文 http://blog.vars.me/blog/2015/04/26/UICollectionView-Optimizing/
主题 iOS开发 C语言
当App中使用了 UICollectionView 以瀑布流的形式来呈现数据时,站在用户的角度,用户在自上至下一页一页浏览这些内容的过程中,当用户感到滑动很流畅自然,每页内容从无到有需要用户等待的时间很短甚至几乎感觉不到,那么 UICollectionView 才会带给用户一个很好的体验。本文介绍了为了达到这两个目的所作出的一些客户端的优化。
数据的预加载
数据预加载的目的是不必等到用户某一时刻浏览到CollectionView的末尾了,也即本地已经没有更多数据展示了才去发请求拿下一页数据,而是有一个预判,用户就快要看完本地的数据了,可以向Server要下一页数据了!
为了实现预加载,最开始的方案是在UI层面的预判。根据 UICollectionView 的基类是 UIScrollView ,大致思路是对于沿竖直方向滚动的CollectionView,考察它的 contentOffset.y 和 conetntSize.height ,结合CollectionView的 frame.size.height ,可以计算CollectionView全部内容底下还有多高没展示出来,如果高度小于我们预先设定的阈值(用户快滑到底了),那么就触发加载下一页的请求。
这样做似乎没什么问题,但是仔细想想,其实并不优雅。一方面,一旦有UI调整的需求,CollectionView每行的高度有调整时,我们也要去调整阈值,来决定是否去请求下一页数据;另一方面,App中不同场景下的CollectionView每行高度不同,需要根据不同场景去Tuning,找出合适的阈值。
后来很自然想到在逻辑上进行预判,也就是我们现在使用的方案。
UICollectionView 每个Cell都需要一个数据模型对象(Data Transfer Object,下称DTO)来支持它的显示,通常客户端拿到的服务端返回的数据后,做一系列的解析,得到一个一个DTO,用以支持CollectionView的展示。到代码层面DTO们被保存在一个数组里,任意时刻在正确的状态下 UICollectionView 的总Cell数量应该跟当前本地DTO的个数相等,Cell跟DTO是一一对应的关系, 数据的预加载本质上就是DTO的预加载 。
用户在滚动 UICollectionView 时,当 UICollectionView 根据预定的配置觉得它该展示某行某列的Cell时,会向它的DataSource[2]发送 collectionView:cellForItemAtIndexPath: 消息[3],询问那行那列该展示什么,这个方法返回一个Cell对象, UICollectionView 拿到这个Cell后就把它展示在相应位置。通常这个方法中要做的重要事情就是去上文提到的保存DTO的数组中根据Cell的行列索引找到这个Cell对应的DTO,根据DTO对Cell配置一番,返回给 UICollectionView 。
顺着这个思路,在这个方法中可以知道当前 UICollectionView 需要展示的Cell的索引,由于Cell跟DTO是一一对应的关系,那我们也知道了当前需要的DTO在总数据模型对象中的索引,当剩下的数据模型对象不够支持一页的显示时,就去请求下一页。
表达的可能有点抽象,假设请求一次Server返回20个DTO,过程可以更形象化一点:
- CollectionView: 数据源数据源,用户滑到第181个Cell要露出来了,快给我!
- DataSource: 好的,我首先要去拿第181个Cell对应的DTO,根据这个配置好一个Cell给你去展示!
等等,你都已经展示到第181个Cell了啊!我发现DTO目前本地总共只有200个,200 - 181 = 19 < 20不够支持你展示下一页所需要的20个Cell了,我先发起一个异步请求,去拿新一页的DTO!
关键代码,很简单:
NSUInteger countOfDataModel = dataModel.count; // 目前本地有的DTO数量
NSUInteger currentRequestIndex = indexPath.row; // 当前需要的Cell索引,也即当前需要的数据模型索引
if (countOfDataModel - currentRequestIndex < 19) {
[self fetchNextPageAsync];
}
要注意的问题是要做好防止重复发送请求的保护工作。
图片加载逻辑优化
当 UICollectionView 的每个Cell都需要展示一个(或多个)图片时,在上文提到的根据DTO配置Cell过程中,会根据DTO中指定的图片的URL,发送一个异步的图片请求,等到图片请求完毕了,再把图片展示到对应的Cell上(当然,可以把这一切交给 SDWebImage : )。
或许你会问,加载图片已经是异步了啊,我还要优化什么?不,这远远不够。在实际的测试中,这种朴素的做法依然会带来明显的滑动过程的卡顿。使用Instruments进行profile发现,在滑动过程中始终会丢那么15帧左右,不能忍!
再回到 UICollectionView 继承自 UIScrollView 上来。通过 UIScrollView 的Delegate,我们能感知到滑动过程中CollectionView的各种关键状态,包括用户的手是否正在拖拽,以及CollectionView是否正在滑动、减速等等,这就是我们优化的秘密武器!
那么,本着不该做的事情不要做,或者等到不得不做的时候再做的原则,让我们分析用户在滑动CollectionView的过程中有哪些地方可以细抠。
用户在滑动(拖拽)CollectionView时(手与屏幕正在接触),很有可能是用户在认真逐个浏览每个Cell,要去加载当前可见Cell的图片
用户滑动CollectionView结束后,手离开了屏幕,并引发了CollectionView减速时, 预判 CollectionView减速结束后静止时的状态,对于那些将来静止时用户可见的Cell,提前去加载它们的图片;对于那些只是“昙花一现”的Cell,即它们只是在减速的过程中出现那么一刹那,就被“顶”上去了,只加载这些Cell中图片在本地有缓存的图片(从内存中加载,不值得去发网络请求,即使是异步的也不值得)
减速结束后,CollectionView处于静止状态,加载当前全部可见Cell的图片
OK,那么来看我们怎么实现它。
对于CollectionView的每个Cell,我们给它添加一个异步加载图片的方法 loadImage 。直接上关键代码,看了便知。
// CollectionView将来静止时可见的区域,同时也是标识CollectionView当前是正在被用户拖拽还是已经被拖拽完毕并正在减速
@property (nonatomic, strong) CGRect *targetRect;
#pragma mark - UICollectionView DataSource
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
// ....
[self loadImageForCell:cell atIndexPath:indexPath];
// ....
}
#pragma mark - UIScrollView Delegate
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
self.targetRect = nil;
[self loadImageForVisibleCells];
}
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
self.targetRect = CGRectMake(targetContentOffset->x, targetContentOffset->y, scrollView.frame.size.width, scrollView.frame.size.height);
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
self.targetRect = nil;
[self loadImageForVisibleCells];
}
#pragma mark - Decide to Load Image For Cells
- (void)loadImageForCell:(AESmartCollectionFlowViewCell *)cell
atIndexPath:(NSIndexPath *)indexPath {
// Cell的targetURLString是指派给Cell的新的图片URL,在根据Cell的DTO配置Cell时为其赋值
if (!cell.targetURLString) {
return;
}
// Cell的imageURLString是Cell的当前正在显示的图片URL
if (![cell.targetURLString isEqualToString:cell.imageURLString] || cell.isDisplayingPlaceholderNow) {
SDWebImageManager *manager = [SDWebImageManager sharedManager];
UICollectionViewLayoutAttributes *attr = [self.collectionView layoutAttributesForItemAtIndexPath:indexPath];
CGRect cellFrame = attr.frame;
BOOL shouldLoadImageForCurrentCell = YES;
// 如果正在减速而且当前Cell的frame不在将来滑动停止后的可见区域
if (self.targetRect && !CGRectIntersectsRect(self.targetRect.CGRectValue, cellFrame)) {
// 那么只有Cell的targetURL在内存的缓存中,才去加载它
SDImageCache *imageCache = [SDImageCache sharedImageCache];
NSString *key = [manager cacheKeyForURL:[NSURL URLWithString:cell.targetURLString]];
if (![imageCache imageFromMemoryCacheForKey:key]) {
shouldLoadImageForCurrentCell = NO;
}
}
if (shouldLoadImageForCurrentCell) {
[cell loadImage];
}
}
}
- (void)loadImageForVisibleCells {
NSArray *visibleCells = [self.collectionView visibleCells];
for (UICollectionViewCell *cell in visibleCells) {
NSIndexPath *indexPath = [self.collectionView indexPathForCell:cell];
[self loadImageForCell:cell atIndexPath:indexPath];
}
}
做了这些努力后,再去profile一下,发现网速良好情况下滑动时帧率只丢了那么1、2帧,而且滑动起来无明显卡顿!
要么不做,要么做绝
哈哈,这个有点狠啊,颇有朱元璋的风格。
做了这么多后,我们发现,数据预加载完毕后,向CollectionView发送 reloadData 消息通知它数据模型变化时,就在这一瞬间,还是会导致CollectionView卡顿那么一下下。
好吧不能忍,封装一个我们自己的 reloadData 方法,在这里简单的hold住reload,根据上文中的 targetRect 属性的标记作用,当且仅当在CollectionView减速停止后,再去真正向它发送 reloadData 消息。在这里仅提供思路,不做赘述了。
此外,在开发中,我们把这一系列的方法以 NSObject 类的Category形式做一个封装,这样不管谁是CollectionView的Delegate或者DataSource都可以从容应对。