WWDC 2018 :CollectionView 之旅

13,967 阅读9分钟

本文是 WWDC 2018 Session 225 读后感,其视频及配套 PDF文稿 地址如下 A Tour Of UICollectionView

这篇文章难度不大,由易到难,逐层深入,是一篇很好的 Session。全文总计约2500字,通读全文花费时间大约15分钟。

看完这篇 Session,给我的直观感受是这篇名为 A Tour Of UICollectionView 的文章,是围绕着一个 CollectionView 的案例,对自定义布局以及其性能优化、数据操作、动画做的一次探讨。虽然没有新增的 API 和特性,但是实际意义蛮大。

我们也按照 Session 的思路,将本文主要分为三个模块:

  • CollectionView 概述
  • 布局(自定义 Layout)
  • 数据的刷新、动画

CollectionView 想必各位已经不陌生了,在我们的日常开发中,它的身影随处可见。如果还有小伙伴对它不熟悉,可以看看之前的 Session :

如果我们想搭建一个如下图的 App ,需要涉及到三点:布局、刷新、动画,我们今天的话题也是围绕着这三点展开。

CollectionView 概述

CollectionView 的核心概念有三点:布局(Layout)、数据源(Data Source)、代理(Delegate)。

UICollectionViewLayout

UICollectionViewLayout 负责管理 UICollectionViewLayoutAttributes,一个 UICollectionViewLayoutAttributes 对象管理着一个 CollectionView 中一个 Item 的布局相关属性。包括 Bounds、center、frame 等。同时要注意在当 Bounds 在改变时是否需要刷新 Layout, 以及布局时的动画。

UICollectionViewFlowLayout

UICollectionViewFlowLayout 是 UICollectionViewLayout 的子类,是系统提供给我们一个封装好的流式布局的类。

横向流式布局(白色线代表布局方向)

纵向流式布局(白色线代表布局方向)

这种流式布局需要区分方向,方向不同,具体的 Line Spacing 和 Item Spacing 所代表的含义不同,具体差异,可以通过上面的两张图进行区分。

因为流式布局其强大的适用性,所以在设计中这种布局方式被广泛使用。

UICollectionViewDataSource

数据源:顾名思义,提供数据的分组信息、每组中 Item 数量以及每个 Item 的实际内容。

optional func numberOfSections(in collectionView: UICollectionView) -> Int

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell

UICollectionViewDelegate

delegate 提供了一些细颗粒度的方法:

  • Highlighting
  • Selection

还有一些视图的显示事件:

  • willDisplayItem
  • didEndDisplayingItem

布局 - 自定义 Layout

系统提供的 UICollectionViewFlowLayout 虽然使用起来方便快捷,能够满足基本的布局需要。但是遇到如下图的布局样式,显然就无法达到我们所需的效果,这时就需要自定义 FlowLayout 了。

自定义 FlowLayout 并不复杂 ,有以下四步:

1.提供滚动范围
override var collectionViewContentSize: CGSize 
2.提供布局属性对象
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? 

func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
3.布局的相关准备工作
// 为每个 invalidateLayout 调用
// 缓存 UICollectionViewLayoutAttributes
// 计算 collectionViewContentSize
func prepare()
4.处理自定义布局中的边界更改
// 在 CollectionView 滚动时是否允许刷新布局
func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool

性能优化部分

通过以上的方法,我们可以轻松实现自定义 layout 的布局。但是在实际开发中,有一个对性能提升很实用的小技巧很值得我们借鉴。

通常,我们获取当前屏幕上所有显示的 UICollectionViewLayoutAttributes 会这么写

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return cachedAttributes.filter { (attributes:UICollectionViewLayoutAttributes) -> Bool in
        return rect.intersects(attributes.frame)
    }
}

采用以上的写法,我们会遍历缓存了所有 UICollectionViewLayoutAttributes 的 cachedAttributes 数组。而随着用户的拖动屏幕,这个方法会被频繁的调用,也就是会做大量的计算。当 cachedAttributes 数组的量级达到一定的规模,对性能的负面影响就会非常明显,用户在使用过程中会出现卡顿的负面体验。

苹果工程师采用的办法可以很好地解决这一问题。所有的 UICollectionViewLayoutAttributes 都按照顺序被存储在 cachedAttributes 数组中,既然是一个有序的数组,那么只要我们通过二分查找,拿到任何一个在当前页面显示的 Attribures 对象,就可以以这个 Attribures 对象为中心,向前向后遍历查找符合条件的 Attribures 对象即可,这样查找的范围就被大大缩小了。相应地,计算量变小,对性能的提升非常明显。

为了让大家易于理解,画了一张图,虽然有点丑,但表达思想足够了。 当前显示的 CollectionView 的范围就是 rect。在 rect 内部通过二分查找,找到第一个合适的 UICollectionViewLayoutAttributes 作为 firstMatchIndex,也就是那个 Attributes 对象。

在 rect 内, firstMatchIndex 以上的 Attributes 都符合 attributes.frame.maxY >= rect.minY,而在 firstMatchIndex 以下的 Attributes 也都符合 attributes.frame.maxY <= rect.maxY 的条件。

优化后的代码如下
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    
    var attributesArray = [UICollectionViewLayoutAttributes]()
    
    // 找到在当前区域内的任何一个 Attributes 的 Index
    guard let firstMatchIndex = binarySearchAttributes(range: 0...cachedAttributes.endIndex, rect:rect) else { return attributesArray }
    
    // 从后向前反向遍历,缩小查找范围
    for attributes in cachedAttributes[..<firstMatchIndex].reversed {
        guard attributes.frame.maxY >= rect.minY  else {break}
        attributesArray.append(attributes)
    }
    // 从前向后正向遍历,缩小查找范围
    for attributes in cachedAttributes[firstMatchIndex...] {
        guard attributes.frame.minY <= rect.maxY  else {break}
        attributesArray.append(attributes)
    }
    
    return attributesArray
}

通过二分查找的方式,在处理当前页面显示的 UICollectionViewLayoutAttributes 的过程中可以减少遍历的数据量,在实际体验中页面滑动更加顺滑,体验更好,这种处理 Attribures 对象的方式,值得我们在开发过程中借鉴。

数据刷新和动画

我们会遇到对 CollectionView 进行编辑的场景,编辑操作一般是新增、删除、刷新、插入等。在本 Session 中,主讲人为我们做了一个示例。

  • 对最后一条数据进行刷新操作
  • 将原本在最后位置的数据移动到第一条的位置
  • 删除原本的第三条数据

为了便于理解,还是贴一下代码吧:

// 原函数
func performUpdates() {
    people[3].isUpdated = true
    
    let movedPerson = people[3]
    people.remove(at:3)
    people.remove(at:2)
    
    people.insert(movedPerson, at:0)
    
    // Update Collection View
    collectionView.reloadItems(at: [IndexPath(item:3, section:0)])
    collectionView.reloadItems(at: [IndexPath(item:2, section:0)])
    collectionView.moveItem(at: IndexPath(item:3, section:0), to:IndexPath(item:0, section:0))
}

这个例子在操作过程中报错,原因如下:我们删除和移动的是同一个索引位置的元素。我们显示地调用了 reloadData() , reloadData() 是一个异步执行的函数,会直接访问数据源方法,进行重新布局,多次调用容易出错,同时这样写也没有动画效果。

performBatchUpdates

上面出错的场景其实挺常见,为了规范操作,避免在编辑的场景下出现问题,应当将对 CollectionView 的新增、删除、刷新、插入等操作都放入到 performBatchUpdates() 中的 updates 闭包内,CollectionView 中 Item 的更新顺序我们不需要关心,但是数据源更新的顺序是很重要的。

首先认识一下这个方法

func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil)

1.其中 updates 闭包内部会执行新增、删除、刷新、插入等一系列操作。

2.而 completion 闭包会在 updates 闭包执行完毕后开始执行,updates 闭包中的相关操作会触发一些动画,
  当这些动画执行成功会返回 True,当动画被打断或者执行失败会返回 false,这个参数也有可能会返回 nil

这个方法可以用来对 collectionView 中的元素进行批量的新增、删除、刷新、插入等操作,同时将触发collectionViewlayout 的对应动画:

1.func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> NSCollectionViewLayoutAttributes?

2.func initialLayoutAttributesForAppearingDecorationElement(ofKind elementKind: NSCollectionView.DecorationElementKind, at decorationIndexPath: IndexPath) -> NSCollectionViewLayoutAttributes?

3.func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> NSCollectionViewLayoutAttributes?

4.func finalLayoutAttributesForDisappearingDecorationElement(ofKind elementKind: String, at decorationIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?

原因是因为在执行完 performBatchUpdates 操作之后,CollectionView 会自动 reloadData 调用数据源方法重新布局。所以我们在 Updates 闭包中对数据的编辑操作执行完毕后,一定要同步更新数据源,否则有极大的几率出现数据越界等错误情况。

易出错的合并更新一般有以下几种

  • 1.移动与删除同一个索引
  • 2.移动与插入同一个索引
  • 3.将多个对象移动到同一个索引
  • 4.引用了一个无效的索引

既然在执行操作时容易出现问题,我们就该想办法去规避,苹果的工程师给出了很好的建议。在上面我们讲过对 CollectionView 的新增、删除、刷新、插入等操作都放入到 performBatchUpdates() 中的 updates 闭包内,CollectionView 中 Item 的更新顺序我们不需要关心,但是数据源更新的顺序很重要。最后的 Item 更新顺序和数据源的更新顺序是怎么回事呢?

你可以这样理解:

  • 在 Updates 闭包内,你可以选择先删除一个索引,然后插入一个新的索引,或是把两者的顺序颠倒过来进行操作,这都没有问题,你可以按照自己的喜好,随意指定顺序。
  • 但是涉及到数据源更新的方法,必须按照一定的顺序和规则来操作。

数据源执行操作的顺序及规则

  • 1.将移动操作拆分成删除和插入。
  • 2.将所有的删除操作合并到一起,同理将所有的插入操作也合并到一起。
  • 3.以降序优先处理删除操作。
  • 4.最后以升序处理插入操作。

然后我们将刚才出错的代码,改为如下:

// 新的实现
func performUpdates() {
    
    UIView.performWithoutAnimation {
        // 先将数据刷新
        CollectionView.performBatchUpdates({
            people[3].isUpdate = true
            CollectionView.reloadItems(at: [IndexPath(item:3, section:0)])
        })
        
        // 再将移动拆分成删除之后再插入两个动作
        CollectionView.performBatchUpdates({
            let movedPerson = people[3]
            people.remove(at: 3)
            people.remove(at: 2)
            people.insert(movedPerson, at:0)
            CollectionView.deleteItems(at: [IndexPath(item:2, section:0)])
            collectionView.moveItem(at: IndexPath(item:3, section:0), to:IndexPath(item:0, section:0))
        })
    }
}

最后总结一下,苹果的工程师建议我们通过自定义布局来实现精美的布局样式,同时采取二分查找的方式来高效的处理数据,提升界面的流畅性和用户体验。

其次对 CollectionView 的操作建议我们通过 performBatchUpdates 来进行处理,我们不需要去考虑动画的执行,因为默认都帮助我们处理好了,我们只需要注意数据源处理的原则和顺序,确保数据处理的安全与稳定。

如果对这篇 Session 很感兴趣的话,可以在 Twitter 上联系作者,只需要在 Twitter 搜索 A Tour Of CollectionView 即可,作者还是很热心的。

最后声明,笔者的英语听力比较惨,有些地方听得不是特别明白,一旦发现我的信息有遗漏或者传达的信息有误,还望大家不吝指教。

查看更多 WWDC 18 相关文章请前往 老司机x知识小集xSwiftGG WWDC 18 专题目录