自定义 UICollectionViewLayout系列——UICollectionViewLayout性能优化和定制

在上一期,我们初步了解了UICollectionViewLayout的核心布局逻辑。这一篇是整个系列的第二篇,本篇的主题是 UICollectionViewLayout 性能优化,在这一篇我们将会从一个瀑布流的实际案例来讲解UICollectionViewLayout的性能优化核心逻辑,以及不同业务情况下的优化方向。

这个系列计划分为两篇,分别是:

  • 了解UICollectionViewLayout

    在这篇我们会先了解UICollectionViewLayout的设计思想、排版规则以及方法时序

  • UICollectionViewLayout性能优化和定制

    在初步了解UICollectionViewLayout的工作原理后,我会以瀑布流界面为例思考如何优化UICollectionViewLayout的性能, 以及如何实现Header悬停等效果

性能优化

布局核心流程

在开始讲述性能优化前,我们需要先了解UICollectionViewLayout是怎么工作的,我们先回顾一下上一次总结的UICollectionViewLayout的前置布局流程

collectionviewlayout1-1

UICollectionViewLayout布局前,prepare的性能会影响UICollectionViewLayout的首屏性能。在前文我们讲到,如果实际布局是规则的,容易推测的,那么不需要把所有布局信息都提前算出来,可以根据布局规则,在layoutAttributesForElement(in:):这个方法里头再来计算。

这是首屏优化的思路,然而我们知道,影响用户体验更多是在快速滚动UICollectionView时的流畅度,那么我们应该如何提升这方面的体验呢?这就需要我们进一步了解UICollectionView的布局失效以及更新流程。

1. 布局数据强制失效

这种情况一般发生在reload,当UIColletionView调用reload,那么UICollectionViewLayoutinvalidateLayout会被调用,此时会将所有系统已取得的 Attribute 全部标记位 invalid 并舍弃,并重新走prepare的首屏布局流程。

需要注意的是,准确的 update 时机并不是调用后,而是在下一次 layout 的 update Cycle 里重新调用prepare。堆栈如图:

collectionviewlayout2-1
collectionviewlayout2-2

整个过程如上图所示。如果在 layout 里面有自己的布局缓冲 cache,还需要同步清空

2.布局数据条件失效

这种情况一般发生在UICollectionViewbounds变化。系统会通过方法shouldInvalidate(forBoundsChange newBounds: CGRect)->Bool询问是否需要重新布局,如果返回 YES,则后续流程和上面相同

collectionviewlayout2-3

一般来说,我们需要刷新布局是在两个条件下:

  1. UICollectionView的宽度(布局方向是纵向)发生变化,这个时候因为宽度的变化往往会导致 cell 的宽高发生变化,需要重新计算布局。Layout 内缓存的布局信息,也需要清空。
  2. 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。以下是整个流程的简图:

collectionviewlayout2-4

以下是UICollectionViewLayoutInvalidationContext的接口,根据这些接口,我们会对其作用理解的更清晰。

@interface UICollectionViewLayoutInvalidationContext : NSObject

@property (nonatomic, readonly) BOOL invalidateEverything; // 设置全失效
@property (nonatomic, readonly) BOOL invalidateDataSourceCounts; // 设置数量变化导致的失效

- (void)invalidateItemsAtIndexPaths:(NSArray *)indexPaths; //设置某个 item 失效
- (void)invalidateSupplementaryElementsOfKind:(NSString *)elementKind atIndexPaths:(NSArray *)indexPaths; //设置 header/footer 失效
- (void)invalidateDecorationElementsOfKind:(NSString *)elementKind atIndexPaths:(NSArray *)indexPaths; //设置 decoration 失效
@property (nonatomic, readonly, nullable) NSArray *invalidatedItemIndexPaths; //所有失效的 item
@property (nonatomic, readonly, nullable) NSDictionary *> *invalidatedSupplementaryIndexPaths; //所有失效的 header/footer
@property (nonatomic, readonly, nullable) NSDictionary *> *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 *previousIndexPathsForInteractivelyMovingItems;
@property (nonatomic, readonly, copy, nullable) NSArray *targetIndexPathsForInteractivelyMovingItems;
@property (nonatomic, readonly) CGPoint interactiveMovementTarget;

@end

可能有读者会注意到invalidateEverythinginvalidateDataSourceCounts是 readonly 的属性。这两个特殊标记是会在触发 collectionView.reloadData()时会被系统自动启用,不能自己设置,并且仍会重新进入配置流程。

collectionviewlayout2-5

性能优化思路

讲到这里,我们对UICollectionView的布局更新逻辑有了深入的了解。性能优化的办法无外乎空间换时间,更多的缓存可以提供更快的响应性能。在实际实践中,Layout 其实是有两级缓存:我们自定义的缓存数据和系统的 LayoutAttributes。综合上面的内容,我们得出性能优化的核心点是:

  1. 减少不必要的刷新

    如当 bounds 仅仅是 offset 变化,而 header/footer 又不悬停,那么这个时候其实是不需要刷新布局的。

  2. 减少缓存的更新

    即便是需要刷新,我们可以控制数据处理范围。比如仅仅是 header 悬停的场景下,那么由于其实所有元素的大小都没有发生变化,仅仅是 header/footer 的位置发生变化。那么我们可以保留所有自定义缓存,仅仅将悬停的元素置为 invalid

  3. 缓存模型的设计

    不同的布局模型下,采用不同的缓存模型会对性能有一定的影响。如果是 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 *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];
}

  1. 指定自定义的UICollectionViewLayoutInvalidationContext

  2. 设定布局更新的条件

  3. 因为只是 offset 的变化,标识keepLayoutAttrs表示不需要更新缓存

  4. 根据当前的可视区域,寻找悬浮的 header/footer 并且标识为 invalid

  5. size 变化,标识 size 的差值

  6. reloaddata、数量更新、尺寸变化这三种情况清空所有缓存

  7. 清除无效的 cell 对应的缓存

  8. 清除无效的 supplyment 对应的缓存

  9. 清楚无效的 decoration 对应的缓存

Header/Footer 悬停

在前面的内容中,其实我们多多少少已经接触到了关于悬停这个常见场景的实现。在实现这个需求的时候,问题关键在于如何确定悬停的Header/Footer、悬停的位置、如何更新对应的layoutAttributes以及滚动性能。为了方便后面的讲述,我们回顾一下UICollectionView的布局。

collectionviewlayout3-1

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 发生变化的时候,我们可以通过shouldInvalidateLayoutForBoundsChangeinvalidationContextForBoundsChange来判断是否需要更新布局,以及如何更新布局。那么类似的,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 的可用性。

你可能感兴趣的:(自定义 UICollectionViewLayout系列——UICollectionViewLayout性能优化和定制)