自定义 UICollectionViewLayout系列——UI
在上一期,我们初步了解了UICollectionViewLayout
的核心布局逻辑。这一篇是整个系列的第二篇,本篇的主题是 UICollectionViewLayout
性能优化,在这一篇我们将会从一个瀑布流的实际案例来讲解UICollectionViewLayout
的性能优化核心逻辑,以及不同业务情况下的优化方向。
这个系列计划分为两篇,分别是:
-
在这篇我们会先了解
UICollectionViewLayout
的设计思想、排版规则以及方法时序 -
UICollectionViewLayout
性能优化和定制在初步了解
UICollectionViewLayout
的工作原理后,我会以瀑布流界面为例思考如何优化UICollectionViewLayout
的性能, 以及如何实现Header
悬停等效果
性能优化
布局核心流程
在开始讲述性能优化前,我们需要先了解UICollectionViewLayout
是怎么工作的,我们先回顾一下上一次总结的UICollectionViewLayout
的前置布局流程
在 UICollectionViewLayout
布局前,prepare
的性能会影响UICollectionViewLayout
的首屏性能。在前文我们讲到,如果实际布局是规则的,容易推测的,那么不需要把所有布局信息都提前算出来,可以根据布局规则,在layoutAttributesForElement(in:):
这个方法里头再来计算。
这是首屏优化的思路,然而我们知道,影响用户体验更多是在快速滚动UICollectionView
时的流畅度,那么我们应该如何提升这方面的体验呢?这就需要我们进一步了解UICollectionView
的布局失效以及更新流程。
1. 布局数据强制失效
这种情况一般发生在reload
,当UIColletionView
调用reload
,那么UICollectionViewLayout
的invalidateLayout
会被调用,此时会将所有系统已取得的 Attribute 全部标记位 invalid 并舍弃,并重新走prepare
的首屏布局流程。
collectionviewlayout2-2需要注意的是,准确的 update 时机并不是调用后,而是在下一次 layout 的 update Cycle 里重新调用
collectionviewlayout2-1prepare
。堆栈如图:
整个过程如上图所示。如果在 layout 里面有自己的布局缓冲 cache,还需要同步清空。
2.布局数据条件失效
这种情况一般发生在UICollectionView
的bounds
变化。系统会通过方法shouldInvalidate(forBoundsChange newBounds: CGRect)->Bool
询问是否需要重新布局,如果返回 YES,则后续流程和上面相同
一般来说,我们需要刷新布局是在两个条件下:
-
UICollectionView
的宽度(布局方向是纵向)发生变化,这个时候因为宽度的变化往往会导致 cell 的宽高发生变化,需要重新计算布局。Layout 内缓存的布局信息,也需要清空。 -
UICollectionView
的 offset 发生变化。如果当前的UICollectionView
有悬停 header/footer 的设计,那么随着用户的不断滚动,header/footer 的 frame 需要不断更新。这个时候,我们需要把在屏幕范围内的 supplymentView 标记为 invalid,Layout 内缓存的布局信息不需要清空。(前提是只缓存了元素的 size,没有缓存元素的偏移点,否则需要更新缓存)
布局失效标记——UICollectionViewLayoutInvalidationContext
正如前面条件失效所提及的,有时候我们并不需要将所有布局信息标记位 invalid,而是仅仅标记一部分。而为了满足这个定制化的能力,iOS 提供了UICollectionViewLayoutInvalidationContext
。和其它 context 类似,这是一个上下文对象。在invalidationContext(forBoundsChange:)
方法中创建一个 context,根据业务需要标记相应的元素为 invalid。接着在invalidateLayout(with:)
中执行失效标记处理,如同步删除 layout 内的缓存数据。整个流程执行结束后,系统会重新询问获取新的layoutAttributes
。以下是整个流程的简图:
以下是UICollectionViewLayoutInvalidationContext
的接口,根据这些接口,我们会对其作用理解的更清晰。
@interface UICollectionViewLayoutInvalidationContext : NSObject
@property (nonatomic, readonly) BOOL invalidateEverything; // 设置全失效
@property (nonatomic, readonly) BOOL invalidateDataSourceCounts; // 设置数量变化导致的失效
- (void)invalidateItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths; //设置某个 item 失效
- (void)invalidateSupplementaryElementsOfKind:(NSString *)elementKind atIndexPaths:(NSArray<NSIndexPath *> *)indexPaths; //设置 header/footer 失效
- (void)invalidateDecorationElementsOfKind:(NSString *)elementKind atIndexPaths:(NSArray<NSIndexPath *> *)indexPaths; //设置 decoration 失效
@property (nonatomic, readonly, nullable) NSArray<NSIndexPath *> *invalidatedItemIndexPaths; //所有失效的 item
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, NSArray<NSIndexPath *> *> *invalidatedSupplementaryIndexPaths; //所有失效的 header/footer
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, NSArray<NSIndexPath *> *> *invalidatedDecorationIndexPaths; //所有失效的 Decoration
@property (nonatomic) CGPoint contentOffsetAdjustment; //contentOffset 差值
@property (nonatomic) CGSize contentSizeAdjustment API_AVAILABLE(ios(8.0)); //contentSize 差值
// Reordering support
@property (nonatomic, readonly, copy, nullable) NSArray<NSIndexPath *> *previousIndexPathsForInteractivelyMovingItems;
@property (nonatomic, readonly, copy, nullable) NSArray<NSIndexPath *> *targetIndexPathsForInteractivelyMovingItems;
@property (nonatomic, readonly) CGPoint interactiveMovementTarget;
@end
可能有读者会注意到invalidateEverything
和invalidateDataSourceCounts
是 readonly 的属性。这两个特殊标记是会在触发 collectionView.reloadData()时会被系统自动启用,不能自己设置,并且仍会重新进入配置流程。
性能优化思路
讲到这里,我们对UICollectionView
的布局更新逻辑有了深入的了解。性能优化的办法无外乎空间换时间,更多的缓存可以提供更快的响应性能。在实际实践中,Layout 其实是有两级缓存:我们自定义的缓存数据和系统的 LayoutAttributes。综合上面的内容,我们得出性能优化的核心点是:
-
减少不必要的刷新
如当 bounds 仅仅是 offset 变化,而 header/footer 又不悬停,那么这个时候其实是不需要刷新布局的。
-
减少缓存的更新
即便是需要刷新,我们可以控制数据处理范围。比如仅仅是 header 悬停的场景下,那么由于其实所有元素的大小都没有发生变化,仅仅是 header/footer 的位置发生变化。那么我们可以保留所有自定义缓存,仅仅将悬停的元素置为 invalid
-
缓存模型的设计
不同的布局模型下,采用不同的缓存模型会对性能有一定的影响。如果是 cell 大小都一致的,那么我们缓存的数据将会非常少,计算量也很小。如果大小不一致还有前后依赖,由于cell 数量的变化,会导致大量的布局重算,那么我们就比较适合仅仅保存 cell 的尺寸而不保存位置信息。
优化实战
依然是以常见的瀑布流布局举例,下面的代码是一个小 demo,可以了解到UICollectionViewLayout
性能优化的具体方法。
首先是每个 section 的缓存数据模型,缓存了各类元素的核心数据
@interface StreamLayoutSectionCache : NSObject
@property (nonatomic, assign) CGFloat headerHeight;
@property (nonatomic, assign) CGFloat cellsHeight;
@property (nonatomic, assign) CGFloat footerHeight;
@property (nonatomic, strong) UICollectionViewLayoutAttributes *headerAttr;
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, UICollectionViewLayoutAttributes *> *cellsAttr;
@property (nonatomic, strong) UICollectionViewLayoutAttributes *footerAttr;
@property (nonatomic, strong) UICollectionViewLayoutAttributes *decorationAttr;
@end
其次是自定义的UICollectionViewLayoutInvalidationContext
,这里增加的属性keepLayoutAttrs
是为了避免不必要的缓存更新
@interface StreamLayoutInvalidationContext : UICollectionViewLayoutInvalidationContext
@property (nonatomic, assign) BOOL keepLayoutAttrs;
@end
接下来是UICollectionViewLayout
的核心布局更新逻辑
+ (Class)invalidationContextClass {
// 1.
return [StreamLayoutInvalidationContext self];
}
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
// 2.
return newBounds.size.width != self.collectionView.width || newBounds.origin.y != self.collectionView.contentOffset.y;
}
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForBoundsChange:(CGRect)newBounds {
StreamLayoutInvalidationContext *context =
(StreamLayoutInvalidationContext *)[super invalidationContextForBoundsChange:newBounds];
if (newBounds.size.width == self.collectionView.width) {
// 3.
context.keepLayoutAttrs = YES;
// 4.
......
} else {
// 5.
context.contentSizeAdjustment =
CGSizeMake(newBounds.size.width - self.collectionView.size.width, newBounds.size.height - self.collectionView.size.height);
}
return context;
}
- (void)invalidateLayoutWithContext:(StreamLayoutInvalidationContext *)context {
if (self.caches && !context.keepLayoutAttrs) {
if (context.invalidateEverything || context.invalidateDataSourceCounts || context.contentSizeAdjustment.width > 0) {
// 6.
self.caches = nil;
} else {
// 7.
if ([context.invalidatedItemIndexPaths count] > 0) {
NSSet *set = [NSSet setWithArray:[context.invalidatedItemIndexPaths map:^id(NSIndexPath *obj, NSUInteger idx) {
return @(obj.section);
}]];
[set enumerateObjectsUsingBlock:^(NSNumber *_Nonnull obj, BOOL *_Nonnull stop) {
NSUInteger section = [obj unsignedIntegerValue];
self.caches[obj].cellsHeight = 0;
self.caches[obj].cellsAttr = [NSMutableDictionary dictionary];
[self prepareCellLayoutForSection:section];
}];
}
// 8.
if ([context.invalidatedSupplementaryIndexPaths count] > 0) {
[[context.invalidatedSupplementaryIndexPaths allKeys]
enumerateObjectsUsingBlock:^(NSString *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if ([obj isEqualToString:UICollectionElementKindSectionHeader]) {
[context.invalidatedSupplementaryIndexPaths[obj]
enumerateObjectsUsingBlock:^(NSIndexPath *_Nonnull indexPath, NSUInteger idx, BOOL *_Nonnull stop) {
self.caches[@(indexPath.section)].headerHeight = 0;
self.caches[@(indexPath.section)].headerAttr = nil;
[self prepareHeaderLayoutForSection:indexPath.section];
}];
} else if ([obj isEqualToString:UICollectionElementKindSectionFooter]) {
[context.invalidatedSupplementaryIndexPaths[obj]
enumerateObjectsUsingBlock:^(NSIndexPath *_Nonnull indexPath, NSUInteger idx, BOOL *_Nonnull stop) {
self.caches[@(indexPath.section)].footerHeight = 0;
self.caches[@(indexPath.section)].footerAttr = nil;
[self prepareFooterLayoutForSection:indexPath.section];
}];
}
}];
}
// 9.
if ([context.invalidatedDecorationIndexPaths count] > 0) {
[[context.invalidatedDecorationIndexPaths allKeys]
enumerateObjectsUsingBlock:^(NSString *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
[context.invalidatedSupplementaryIndexPaths[obj]
enumerateObjectsUsingBlock:^(NSIndexPath *_Nonnull indexPath, NSUInteger idx, BOOL *_Nonnull stop) {
self.caches[@(indexPath.section)].decorationAttr = nil;
[self prepareDecorationLayoutForSection:indexPath.section];
}];
}];
}
}
}
[super invalidateLayoutWithContext:context];
}
-
指定自定义的
UICollectionViewLayoutInvalidationContext
-
设定布局更新的条件
-
因为只是 offset 的变化,标识
keepLayoutAttrs
表示不需要更新缓存 -
根据当前的可视区域,寻找悬浮的 header/footer 并且标识为 invalid
-
size 变化,标识 size 的差值
-
reloaddata、数量更新、尺寸变化这三种情况清空所有缓存
-
清除无效的 cell 对应的缓存
-
清除无效的 supplyment 对应的缓存
-
清楚无效的 decoration 对应的缓存
Header/Footer 悬停
在前面的内容中,其实我们多多少少已经接触到了关于悬停这个常见场景的实现。在实现这个需求的时候,问题关键在于如何确定悬停的Header/Footer
、悬停的位置、如何更新对应的layoutAttributes
以及滚动性能。为了方便后面的讲述,我们回顾一下UICollectionView
的布局。
1.获得悬停位置
要正确处理好悬停的逻辑,首先就要确定好悬停的位置。可能会有人说可以通过 delegate 让外部传入正确的值,但这明显增加了使用者的使用难度。而类似的,UITableView 的 header 悬停并不需要外部介入。那么在UICollectionViewLayout
的布局中,我们就需要处理自行处理好可视区域的问题。幸运的是,在 UICollectionView 中,我们可以使用adjustedContentInset
来判断可视区域范围。实际公式如下:
topVisible = collectionView.contentOffset.y + collectionView.adjustedContentInset.top;
bottomVisible = collectionView.contentOffset.y + collectionView.height - collectionView.adjustedContent.top - collectionView.adjusted.bottom;
2.更新位置信息
在第一期的文章中,我们就知道了Header/Footer
的位置和layoutAttributesForSupplementaryViewOfKind:atIndexPath:
息息相关。我们需要在这个方法中按照上面的规则计算出新的位置,并返回被布局系统,才能保证Header/Footer
的位置保持不便。
- (UICollectionViewLayoutAttributes *)layoutAttributesForSupplementaryViewOfKind:(NSString *)elementKind atIndexPath:(NSIndexPath *)indexPath {
NSInteger section = indexPath.section;
WCFinderStreamLayoutSectionCache *cache = self.caches[@(indexPath.section)];
//section的起点
CGFloat top = [self contentHeightToSection:section - 1];
if ([elementKind isEqualToString:UICollectionElementKindSectionHeader]) {
//collectionView可视区域相对 collectionView 的 bounds 的位置
CGFloat offset = self.collectionView.contentOffset.y + self.collectionView.adjustedContentInset.top;
if ([self headerPinToVisibleBoundsInSection:section]) {
//section 的终点
CGFloat bottom = top + cache.headerHeight + cache.cellsHeight;
//section 和可视区域有重叠,即需要处理 header 悬浮
if (top < offset && bottom > offset) {
if (bottom - cache.headerHeight < offset) {
top = bottom - cache.headerHeight;
} else {
top = offset;
}
}
}
}
UICollectionViewLayoutAttributes *attrs = [cache layoutAttributesForSupplementaryViewOfKind:elementKind];
attrs.zIndex = 1;
return [self copyAttributes:attrs withDeltaTop:top];
}
上面这段代码是当header
要悬浮时计算header
位置的处理逻辑。但是你如果设置断点,可能会发现这个方法不会被调用,这里就又有一个关键的地方是,我们需要在滚动时告知UICollectionView
去更新header
。
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForBoundsChange:(CGRect)newBounds {
WCFinderStreamLayoutInvalidationContext *context =
(WCFinderStreamLayoutInvalidationContext *)[super invalidationContextForBoundsChange:newBounds];
if (newBounds.size.width == self.collectionView.width) {
context.keepLayoutAttrs = YES;
NSUInteger topVisibleSection = [self topVisibleSectionInBounds:newBounds];
if ([self headerPinToVisibleBoundsInSection:topVisibleSection]) {
//标记 header 位置无效
[context invalidateSupplementaryElementsOfKind:UICollectionElementKindSectionHeader
atIndexPaths:@[ [NSIndexPath indexPathWithIndex:topVisibleSection] ]];
}
} else {
context.contentSizeAdjustment =
CGSizeMake(newBounds.size.width - self.collectionView.size.width, newBounds.size.height - self.collectionView.size.height);
}
return context;
}
这样,在 UICollectionView 滚动的时候就会不断的调用前面的方法更新header
的位置了。
支持 self-sizing
在大部分的时候,cell 的高度我们是通过layout 的 delegate 回调来获取。但有些时候在 cell 上我们使用了 Autolayout 等自动布局技术,我们并不想重新写一个计算高度的方法。于是在UICollectionViewFlowLayout
上有estimateItemSize
等类似属性。这些属性的作用是,由调用者提供一个预估的 size,布局的时候先用这个预估的 size 进行布局计算。当 cell 即将出现的时候,会通过preferredLayoutAttributesFittingAttributes:
方法询问实际布局的数据。
UICollectionViewFlowLayout 的 self-sizing
简而言之,如果你使用的是UICollectionViewFlowLayout
,那么可以通过在 cell 添加下面的代码来
- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes {
//注意这里必须先调用 super 的方法,然后在这个返回值的基础上修改 frame
UICollectionViewLayoutAttributes *attributes = [super preferredLayoutAttributesFittingAttributes:layoutAttributes];
CGRect frame = attributes.frame;
frame.size.height = self.containerView.height;
attributes.frame = frame;
return attributes;
}
这里代码的意思是 cell 的高度将以self.containerView
为准(前提是这个containerView
的高度是准确的)。
自定义UICollectionViewLayout的 self-sizing
那么如果是自定义UICollectionViewLayout,我们就需要知道在什么时候UICollectionViewLayoutAttributes
发生了改变这样才能及时更新布局。在第一期的时候,我们知道了在 bounds 发生变化的时候,我们可以通过shouldInvalidateLayoutForBoundsChange
和invalidationContextForBoundsChange
来判断是否需要更新布局,以及如何更新布局。那么类似的,UICollectionViewLayout
也提供了shouldInvalidateLayoutForPreferredLayoutAttributes:withOriginalAttributes:
和invalidationContextForPreferredLayoutAttributes
。
- (BOOL)shouldInvalidateLayoutForPreferredLayoutAttributes:(UICollectionViewLayoutAttributes *)preferredAttributes withOriginalAttributes:(UICollectionViewLayoutAttributes *)originalAttributes {
//判断 attributes 的 frame 是否发生变化,如果发生变化则需要刷新布局
return !CGRectEqualToRect(preferredAttributes.frame, originalAttributes.frame);
}
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForPreferredLayoutAttributes:(UICollectionViewLayoutAttributes *)preferredAttributes withOriginalAttributes:(UICollectionViewLayoutAttributes *)originalAttributes {
WCFinderStreamLayoutInvalidationContext *context =
(WCFinderStreamLayoutInvalidationContext *)[super invalidationContextForPreferredLayoutAttributes:preferredAttributes withOriginalAttributes:originalAttributes];
//在 context 里面标记发生了变化的 item
[context invalidateItemsAtIndexPaths:@[originalAttributes.indexPath]];
return context;
}
当我们添加了以上两个代码后,当实际 cell 的宽度和 estimateItemSize
不符的时候,我们就可以在invalidateLayoutWithContext
处理布局更新逻辑。
严格来说,因为cell 的 size 的变化,我们还需要处理 collectionView 的 contentSize 变化。以及在prepareLayout
,我们需要改用 estimateItemSize
来做预布局。
总结
在这篇文章,我们详细了解了UICollectionViewLayout
的布局更新过程和性能优化思路,这可以大大提升UICollectionView
的滚动性能,并保证行为合乎系统逻辑。同时我们也分享了如何实现类似悬停 header 等特殊情况的业务定制,这大大提高了自定义 Layout 的可用性。