美文网首页
自定义 UICollectionViewLayout系列——UI

自定义 UICollectionViewLayout系列——UI

作者: felix9 | 来源:发表于2022-10-20 11:48 被阅读0次

    在上一期,我们初步了解了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<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
    

    可能有读者会注意到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<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];
    }
    
    
    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系列——UI

          本文链接:https://www.haomeiwen.com/subject/dokuzrtx.html