前言
在之前的一篇文章中,我们探讨了在iOS手机应用中实现流畅滚动的一些常见策略。应用这些策略的主要目标是避免起伏滚动(choppy scrolling),这是一个对用户体验产生负面影响的常见问题。为了帮助开发者完成这样的任务,苹果在iOS10
中对UICollectionView
做了一些非常有用的改变。但是在回顾这个新引入的功能之前,让我们先看看是什么促使了对它们的需求。
是什么导致滚动出现起伏?
你是否曾经接触过或开发过偶尔出现起伏不平的应用?如果答案是肯定的,那么你就会知道当你尝试快速滚动时,应用程序的内容似乎是断断续续的(stutter),这是多么令人失望。你可能会问自己,是什么引发了这种起伏不定的滚动行为,以及随之而来的糟糕的用户体验。
简短的回答是:该应用正在掉帧。但这到底是什么意思呢?
为了确保持续流畅的滚动,应用程序需要能够稳定地显示60 FPS
(帧每秒)。或者,换句话说,应用程序需要每秒刷新60次内容。这意味着每一帧大约有16ms
的渲染时间(1000ms/60frames ~ 16ms/frame
)。在不幸的情况下,显示一帧的时间超过了规定的时间,没有显示下一帧的数据,这被认为是应用程序“丢失了一帧”。这个不幸的场景如下图所示。蓝色标记表示绘图操作,其粗细(thickness)表示完成渲染所需的时间。正如我们所看到的,在第二帧中,我们有一些渲染事件花费的时间超过了分配的时间(约16ms),因此,第三帧被丢弃了。
我们可以从刷新操作所花费的CPU时间
的角度来可视化相同的场景。在下面的图表中,峰值对应于当应用程序花费超过预期的16毫秒
来刷新当前内容时出现的丢弃帧。
为了获得良好的用户体验,刷新时间必须始终低于允许的最大值~16ms
。理想情况下,当我们想要创造一个很棒的用户体验时(and not just a good one),每次刷新时间应该是:
- 始终远低于允许的最大时间(~16ms)。
- 尽可能低,以节省CPU时间,这些时间可以用于其他任务。
丢帧最常见的原因是从主线程为cell加载昂贵的数据模型。此场景的典型示例如下:
- 从URL加载图像。
- 从数据库或CoreData访问items。
在iOS10
中,苹果对单元格的加载和显示方式进行了一些优化。让我们来看看iOS10
的改进,以及它们是如何让开发者更容易地创建流畅的滚动用户体验的。
iOS9中的Cell生命周期
UICollectionViewCell
的生命周期可以可视化如下:
集合视图和它的单元格之间的主要交互是:
- 集合视图正在请求将要显示的单元格的内容——单元格即将进入可见区域:
collectionView(_:cellForItemAt:)
。 - 集合视图请求显示单元格——单元格刚刚进入可见区域:
collectionView(_:willDisplay:forItemAt:)
。 - 收集视图正在删除单元格-单元格在可见区域之外:
collectionView(didenddisplay:forItemAt:)
。
iOS10中的Cell生命周期
在iOS10
中,cell的生命周期与iOS9
基本相同。然而,有一些显著的区别。
第一个区别是操作系统调用collectionView(_:cellForItemAt:)
比以前早得多。这意味着两件事:
- 加载单元格的繁重工作可以在需要显示单元格之前完成。
- 单元格可能最终根本不显示(因为它可能从未被带入可见区域)。
第二个不同之处在于,当一个cell离开可见区域时会发生什么。在iOS10
中,像往常一样调用collectionView(didenddisplay:forItemAt:)
,但单元格不会立即回收。操作系统会保留它一段时间以防用户反转滚动方向;如果发生这种情况,单元格仍然可用,并且可以再次显示(将调用collectionView(_:willDisplay:forItemAt:)
),而不必重新加载其内容。
将此与iOS9
上的情况进行比较。您将注意到,对于这个特定的用例,不再需要重新加载单元格(collectionView(_:cellForItemAt:)
)。这种优化允许我们在用户快速滚动和改变滚动方向时更快地渲染单元格。
iOS9
和iOS10
之间的第三个重要区别是为多列布局的collection views加载单元格的方式。
在iOS10
中,关于进入可见区域的每个单元格都是分开(separately)加载的(collectionView(_:cellForItemAt:)
)。正如我们在审查(examining)cell生命周期时所看到的,这比实际需要显示每个cell的时间要早得多。这为优化打开了大门,因为操作系统将能够处理不同的请求,将它们与单元格的加载解耦(interleaving)。
当一行单元格(a row of cells)进入可见区域时,这些单元格将作为单个批处理(a single batch)显示(同时对整行调用collectionView(_:willDisplay:forItemAt:)
),因为此操作在CPU周期方面不是很昂贵(至少与加载单元格内容相比)。
加载多列布局的差异是iOS10
最重要的UICollectionView
优化的核心:预抓取(Pre-Fetching)。让我们更详细地探讨一下。
预抓取API
在发布iOS10
时,苹果将预抓取吹捧(touted)为一种自适应技术(Adaptive Technology)。这意味着pre - fetching
将尝试利用用户与应用交互的方式来执行一些优化,以提高滚动性能。例如,这项新技术将寻找空闲时间(当用户缓慢滚动或根本不滚动时)来加载(Pre-Fetch)新的单元格。根据用户滚动模式(scrolling patterns)的不同,Pre-Fetching
执行这种优化的机会可能更多(或更少)。
在查看可用的API之前,让我们看一下使用该技术的一些最佳实践。为了充分利用预抓取,设置单元格内容的主体(bulk)必须在collectionView(_:cellForItemAt:)
中执行。在collectionView(_:willDisplay:forItemAt:)
和collectionView(didenddisplay:forItemAt:)
中执行的操作应该保持最小化,并且必须是非cpu密集型的(non-CPU intensive)。更好的是,如果我们可以对这些生命周期事件根本不执行任何操作,那将是最优的!另外,请记住,即使为单元格调用了collectionView(_:cellForItemAt:)
,仍然有可能永远不会显示该单元格。
一些非常好的消息是,在iOS10
上编译的应用程序默认启用预抓取。但是,可以通过将UICollectionView
的isPrefetchingEnabled
属性设置为false
来关闭此功能。同样重要的是要注意,预取与单元格生命周期一起工作。这意味着我们为实现集合视图而编写的代码不需要修改——要充分利用预抓取功能,唯一需要做的就是实现预抓取API
。
预取API和UICollectionView
UICollectionView
的预取API
是在uicollectionviewdatasourcepre
抓取协议中定义的。API
定义了以下两个方法。
collectionView(_:prefetchItemsAt:)
(required) -该方法允许启动(initiate)[IndexPath]
参数指定的单元格所需的数据的异步加载。异步加载既可以通过Grand Central Dispatch
执行,也可以通过OperationQueue
执行。在实现这些方法时,最重要的是编写代码,将数据加载的负担从主队列转移到后台队列。这样做的目的是减少主队列的工作负载,使其能够将大部分时间用于执行显示刷新等关键任务。
collectionView(_:cancelPrefetchingForItemsAt:)
(optional)-该方法通知(communicates)不再需要[IndexPath]
参数指定的单元格的数据。实现这个方法允许我们根据需要取消挂起的数据加载,这是通过取消不必要的工作(通常是因为用户改变了滚动方向)来节省CPU时间的好方法。
如前所述,预取是一种自适应技术(Adaptive Technology)。因此,上述方法是基于用户与应用程序交互的方式触发的。这样做的一个后果是,可能不会对集合视图中的每个单元格都调用collectionView(_:prefetchItemsAt:)
方法。这意味着,当通过collectionView(_:cellForItemAt:)
加载单元格时,应用程序应该能够处理以下所有场景:
- 数据已经预取完成,准备好了可以显示(The data has been pre-fetched and is ready to be displayed)。
- 正在提取数据,未准备好显示数据(The data is currently being fetched and is not ready to be displayed)。
- 数据还没被请求(The data has not been requested yet)。
预取API和UITableView
iOS10
还为UITableView
引入了Pre-Fetching
功能。我们为UICollectionView
演示的所有主要概念都以类似的方式应用于UITableView
。UITableView
的预取API在UITableViewPrefetchingDataSource
协议中定义。API
定义了以下两个方法。
tableView(_:prefetchRowsAt:)
(required) -这个方法允许启动异步加载[IndexPath]
参数指定的单元格所需的数据。
tableView(_:cancelPrefetchingForRowsAt:)
(optional) -此方法通知不再需要[IndexPath]
参数指定的单元格数据。
为每个UICollectionView
预取API方法提出的建议同样适用于每个UITableView
预取API方法。
总结
我已经更新了UITableView
和UICollectionView
的代码样本,以支持iOS10
上的预抓取。你可以在这里找到。
在这篇文章中,我们回顾了iOS10
在提升UICollectionView
和UITableView
平滑滚动方面的改进。特别是,我们看到,通过实现特定的预取API,可以充分利用所有新的操作系统优化,并在与移动应用程序交互时提供最佳的用户体验。