版本记录
版本号 | 时间 |
---|---|
V1.0 | 2018.11.28 星期三 |
前言
iOS中有关视图控件用户能看到的都在UIKit框架里面,用户交互也是通过UIKit进行的。感兴趣的参考上面几篇文章。
1. UIKit框架(一) —— UIKit动力学和移动效果(一)
2. UIKit框架(二) —— UIKit动力学和移动效果(二)
3. UIKit框架(三) —— UICollectionViewCell的扩张效果的实现(一)
4. UIKit框架(四) —— UICollectionViewCell的扩张效果的实现(二)
5. UIKit框架(五) —— 自定义控件:可重复使用的滑块(一)
6. UIKit框架(六) —— 自定义控件:可重复使用的滑块(二)
7. UIKit框架(七) —— 动态尺寸UITableViewCell的实现(一)
8. UIKit框架(八) —— 动态尺寸UITableViewCell的实现(二)
开始
首先看一下写作环境
Swift 4.2, iOS 12, Xcode 10
作为开发人员,您应始终努力提供出色的用户体验。在显示列表的应用程序中你需要保证出色一种方法是确保滚动顺滑。在iOS 10中,Apple引入了UICollectionView
预取API和相应的UITableView
预取API,允许您在Collection Views and Table Views
需要之前获取数据。
当您遇到滚动阻塞感很强的应用程序时,这通常是由于长时间运行的进程阻塞主UI线程更新。您希望保持主线程可以自由响应触摸事件等事情。如果您花费很长时间来获取和显示数据,用户可以原谅您,但如果您的应用没有响应他们的手势,他们就不会宽恕。将繁重的工作转移到后台线程是构建响应式应用程序的第一步。
在本教程中,您将开始使用EmojiRater
,这是一个显示表情符号集合的应用程序。不幸的是,它的滚动性能还有很多不足之处。您将使用预取API来查找应用可能很快显示的单元格,并在后台触发相关数据提取。
打开并运行示例应用程序,如下所示:
很难受,不是吗? 好消息是你可以解决这个问题。
关于应用程序的一点。 该应用程序显示emojis的集合视图,您可以向下或向下投票。 要使用,请单击其中一个单元格,然后用力按直到您感觉到某些触觉反馈。 应出现评级选择。 选择一个并在更新的集合视图中查看结果:
注意:如果您无法在模拟器中使用3D Touch,则首先需要具有
“Force Touch”
功能的触控板的Mac
或MacBook
。 然后,您可以转到System Preferences ▸ Trackpad
并启用Force Click and haptic feedback
。 如果您无法访问此类设备或使用3D Touch
的iPhone,您仍然可以获得本教程的基本知识。
看看Xcode中的项目。 这些是主要文件:
-
EmojiRating.swift
:表示表情符号的模型。 -
DataStore.swift
:加载一个表情符号。 -
EmojiViewCell.swift
:显示表情符号的集合视图单元格。 -
RatingOverlayView.swift
:允许用户对表情符号进行评级的视图。 -
EmojiViewController.swift
:在集合视图中显示表情符号。
您将向DataStore
和EmojiViewController
添加功能以增强滚动性能。
Understanding Choppy Scrolling - 了解断续的滚动
您可以通过确保您的应用程序满足每秒60帧(FPS)显示约束来实现平滑滚动。 这意味着您的应用程序需要能够每秒刷新其UI 60次,因此每个帧大约需要16毫秒来呈现内容。 系统会丢弃需要太长时间才能显示内容的帧。
当应用程序跳过帧并移动到下一帧时,这会导致不稳定的滚动体验。 丢帧的可能原因是长时间运行阻塞主线程的操作。
Apple提供了一些方便的工具来帮助您。 首先,您可以拆分长时间运行的操作并将它们移动到后台线程。 这允许您在主线程上处理任何触摸事件。 后台操作完成后,您可以根据操作在主线程上进行任何所需的UI更新。
以下显示了丢帧的情况:
将工作移至后台后,事情如下所示:
您现在有两个并发线程正在运行以提高应用程序的性能。
如果你可以在必须显示之前开始获取数据,那会不会更好? 这就是UITableView
和UICollectionView
预取API的用武之地。您将在本教程中使用集合视图API。
Loading Data Asynchronously - 异步加载数据
Apple提供了多种方法来为您的应用添加并发性。 您可以使用Grand Central Dispatch (GCD)作为轻量级机制来同时执行任务。 或者,您可以使用构建在GCD之上的Operation。
Operation
会增加更多开销,但可以轻松重用和取消操作。 您将在本教程中使用Operation
,以便您可以取消之前开始加载不再需要的表情符号的操作。
现在是时候开始研究哪里可以最好地利用EmojiRater
中的并发性。
打开EmojiViewController.swift
并找到数据源方法collectionView(_:cellForItemAt :)
。 看下面的代码:
if let emojiRating = dataStore.loadEmojiRating(at: indexPath.item) {
cell.updateAppearanceFor(emojiRating, animated: true)
}
这会在显示之前从数据存储中加载表情符号。 让我们来看看它是如何实现的。
打开DataStore.swift
并查看加载方法:
public func loadEmojiRating(at index: Int) -> EmojiRating? {
if (0..<emojiRatings.count).contains(index) {
let randomDelayTime = Int.random(in: 500..<2000)
usleep(useconds_t(randomDelayTime * 1000))
return emojiRatings[index]
}
return .none
}
此代码在随机延迟(500ms到2,000ms)之后返回有效的表情符号。 延迟是在不同条件下对网络请求的仿真模拟。
问题发现了! 表情符号提取发生在主线程上,违反了16ms
阈值,触发丢帧。 你即将解决这个问题。
将以下代码添加到DataStore.swift
的末尾:
class DataLoadOperation: Operation {
// 1
var emojiRating: EmojiRating?
var loadingCompleteHandler: ((EmojiRating) -> Void)?
private let _emojiRating: EmojiRating
// 2
init(_ emojiRating: EmojiRating) {
_emojiRating = emojiRating
}
// 3
override func main() {
// TBD: Work it!!
}
}
Operation
是一个抽象类,您必须使用它的子类才能实现要从主线程移出的工作。
以下是代码中一步一步发生的事情:
- 1) 创建对此操作中将使用的表情符号和完成处理程序的引用。
- 2) 创建一个指定的初始化程序,允许您传入表情符号。
- 3) 重写
main()
方法以执行此操作的实际工作。
现在,将以下代码添加到main()
:
// 1
if isCancelled { return }
// 2
let randomDelayTime = Int.random(in: 500..<2000)
usleep(useconds_t(randomDelayTime * 1000))
// 3
if isCancelled { return }
// 4
emojiRating = _emojiRating
// 5
if let loadingCompleteHandler = loadingCompleteHandler {
DispatchQueue.main.async {
loadingCompleteHandler(self._emojiRating)
}
}
下面进行细分
- 1) 在开始之前检查取消。 在尝试长期或密集的工作之前,
Operations
应定期检查是否已取消。 - 2) 模拟长时间运行的表情符号提取。 这段代码应该很熟悉。
- 3) 检查操作是否已取消。
- 4) 分配表情符号以指示提取已完成。
- 5) 在主线程上调用完成处理程序,传入表情符号。 然后,这应该触发UI更新以显示表情符号。
将loadEmojiRating(at :)
替换为以下内容:
public func loadEmojiRating(at index: Int) -> DataLoadOperation? {
if (0..<emojiRatings.count).contains(index) {
return DataLoadOperation(emojiRatings[index])
}
return .none
}
原始代码有两处更改:
- 1) 您创建一个
DataLoadOperation()
以在后台获取表情符号。 - 2) 此方法现在返回
DataLoadOperation
可选,而不是EmojiRating
可选。
您现在需要处理方法签名更改并使用您的全新operation
。
打开EmojiViewController.swift
,并在collectionView(_:cellForItemAt :)
中删除以下代码:
if let emojiRating = dataStore.loadEmojiRating(at: indexPath.item) {
cell.updateAppearanceFor(emojiRating, animated: true)
}
您将不再启动此数据源方法的数据提取。 相反,您将在应用程序即将显示集合视图单元格时调用的委托方法中执行此操作。
在类顶部附近添加以下属性:
let loadingQueue = OperationQueue()
var loadingOperations: [IndexPath: DataLoadOperation] = [:]
第一个属性包含操作队列。 loadingOperations
是一个跟踪数据加载操作的数组,通过索引路径将每个加载操作与其对应的单元相关联。
将以下代码添加到文件末尾:
// MARK: - UICollectionViewDelegate
extension EmojiViewController {
override func collectionView(_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
guard let cell = cell as? EmojiViewCell else { return }
// 1
let updateCellClosure: (EmojiRating?) -> Void = { [weak self] emojiRating in
guard let self = self else {
return
}
cell.updateAppearanceFor(emojiRating, animated: true)
self.loadingOperations.removeValue(forKey: indexPath)
}
// 2
if let dataLoader = loadingOperations[indexPath] {
// 3
if let emojiRating = dataLoader.emojiRating {
cell.updateAppearanceFor(emojiRating, animated: false)
loadingOperations.removeValue(forKey: indexPath)
} else {
// 4
dataLoader.loadingCompleteHandler = updateCellClosure
}
} else {
// 5
if let dataLoader = dataStore.loadEmojiRating(at: indexPath.item) {
// 6
dataLoader.loadingCompleteHandler = updateCellClosure
// 7
loadingQueue.addOperation(dataLoader)
// 8
loadingOperations[indexPath] = dataLoader
}
}
}
}
这将为UICollectionViewDelegate
创建一个扩展,并实现collectionView(_:willDisplay:forItemAt :)
委托方法。 下面进行细说分解:
- 1) 创建一个闭包来处理加载数据后如何更新单元格。
- 2) 检查单元是否有数据加载操作。
- 3) 检查数据加载操作是否已完成。 如果是这样,请更新单元格的UI并从跟踪阵列中删除操作。
- 4) 如果尚未获取表情符号,则将闭包分配给数据加载完成处理程序。
- 5) 如果没有数据加载操作,请为相关表情符号创建一个新的操作。
- 6) 将闭包添加到数据加载完成处理程序。
- 7) 将操作添加到操作队列。
- 8) 将数据加载器添加到操作跟踪阵列。
从集合视图中删除单元格时,您需要确保进行一些清理。
将以下方法添加到UICollectionViewDelegate
扩展:
override func collectionView(_ collectionView: UICollectionView,
didEndDisplaying cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
if let dataLoader = loadingOperations[indexPath] {
dataLoader.cancel()
loadingOperations.removeValue(forKey: indexPath)
}
}
此代码检查与cell关联的现有数据加载操作。如果存在,则取消下载并从跟踪操作的阵列中删除操作。
构建并运行应用程序。滚动表情符号并注意应用程序性能的改进。
如果您可以乐观地获取数据以预期显示集合视图单元格,那就更好了。您将使用预取API来执行此操作并为EmojiRater
提供额外的提升。
Enabling UICollectionView Prefetching - 启用UICollectionView预取
UICollectionViewDataSourcePrefetching
协议为您提前发出警告,即可能很快需要集合视图的数据。您可以使用此信息开始预取数据,以便在单元格可见时,数据可能已经可用。这与你已经完成的并发工作一起工作 - 关键的区别在于工作开始时。
下图显示了这种情况如何发挥作用。用户在集合视图上向上滚动。黄色单元格应该很快进入视图 - 假设这发生在Frame 3
中,并且您目前处于Frame 1
。
采用prefetch
协议会通知应用程序有关可能变为可见的下一个单元格。 如果没有prefetch
触发器,黄色单元的数据提取将在Frame 3
开始,并且cell的数据在一段时间后变为可见。 由于prefetch
,单元格数据将在单元格可见时准备就绪。
打开EmojiViewController.swift
并将以下代码添加到文件末尾:
// MARK: - UICollectionViewDataSourcePrefetching
extension EmojiViewController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
print("Prefetch: \(indexPaths)")
}
}
EmojiViewController
现在采用UICollectionViewDataSourcePrefetching
并实现所需的委托方法。 该实现只是打印出很快就可以看到的索引路径。
在viewDidLoad()
中,在调用super.viewDidLoad()
之后添加以下内容:
collectionView?.prefetchDataSource = self
这将EmojiViewController
设置为集合视图的预取数据源。
构建并运行应用程序,在滚动之前,检查Xcode的控制台。 你应该看到这样的东西:
Prefetch: [[0, 10], [0, 11], [0, 12], [0, 13], [0, 14], [0, 15]]
这些对应于尚未变得可见的cell。 现在,滚动更多并像您一样检查控制台日志。 您应该看到基于不可见的索引路径的日志消息。 尝试向上和向下滚动,直到您充分了解这一切是如何工作的。
您可能想知道为什么这个委托方法只是为您提供索引路径。 我们的想法是你应该从这个方法开始你的数据加载过程,然后在collectionView(_:cellForItemAt :)
或collectionView(_:willDisplay:forItemAt :)
中处理结果。 请注意,当立即需要单元格时,不会调用委托方法。 因此,您应该不依赖于在此方法中将数据加载到单元格中。
Prefetching Data Asynchronously - 异步预取数据
在EmojiViewController.swift
中,通过使用以下内容替换print()
语句来修改collectionView(_:prefetchItemsAt :)
:
for indexPath in indexPaths {
// 1
if let _ = loadingOperations[indexPath] {
continue
}
// 2
if let dataLoader = dataStore.loadEmojiRating(at: indexPath.item) {
// 3
loadingQueue.addOperation(dataLoader)
loadingOperations[indexPath] = dataLoader
}
}
代码循环遍历方法接收的索引路径并执行以下操作:
- 1) 检查此cell是否存在现有的加载操作。 如果有的话,没有更多的事要做。
- 2) 如果找不到加载操作,则创建数据加载操作。
- 3) 将操作添加到队列并更新跟踪数据加载操作的字典。
传递到collectionView(_:prefetchItemsAt :)
的索引路径按优先级排序,基于到集合视图视图的单元格几何距离。 这允许您获取最有可能需要的单元格。
回想一下,您之前在collectionView(_:willDisplay:forItemAt :)
中添加了代码来处理加载操作的结果。 请查看以下方法的重点:
override func collectionView(_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
// ...
let updateCellClosure: (EmojiRating?) -> Void = { [weak self] emojiRating in
guard let self = self else {
return
}
cell.updateAppearanceFor(emojiRating, animated: true)
self.loadingOperations.removeValue(forKey: indexPath)
}
if let dataLoader = loadingOperations[indexPath] {
if let emojiRating = dataLoader.emojiRating {
cell.updateAppearanceFor(emojiRating, animated: false)
loadingOperations.removeValue(forKey: indexPath)
} else {
dataLoader.loadingCompleteHandler = updateCellClosure
}
} else {
// ...
}
}
创建cell更新闭包后,检查跟踪操作的数组。 如果存在即将出现的单元格且表情符号可用,则更新单元格的UI。 请注意,传递给数据加载操作的闭包也会更新单元格的UI。
这就是所有内容的关系,从预取触发操作到正在更新的单元UI。
构建并运行应用程序并滚动表情符号。 滚动到的Emojis应该比以前更快地显示。
你能发现一些可以改进的东西吗?如果您滚动得非常快,那么您的collection view
将开始获取可能永远不会看到的表情符号。 请继续阅读。
Canceling a Prefetch - 取消预取
UICollectionViewDataSourcePrefetching
具有可选的委托方法,可让您知道不再需要数据。 这可能发生,因为用户已经开始非常快地滚动并且可能不会看到中间单元。 您可以使用委托方法取消任何挂起的数据加载操作。
仍然在EmojiViewController.swift
中,将以下方法添加到您的UICollectionViewDataSourcePrefetching
协议实现:
func collectionView(_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
if let dataLoader = loadingOperations[indexPath] {
dataLoader.cancel()
loadingOperations.removeValue(forKey: indexPath)
}
}
}
代码循环遍历索引路径并查找附加到它们的任何加载操作。 然后它取消操作并将其从跟踪操作的字典中删除。
构建并运行应用程序。 当您快速滚动时,可能已开始的操作应该开始取消。 在视觉上,事情看起来不会有太大的不同。
需要注意的一点是,由于cell重用,可能需要重新获取一些先前可见的cell。
后记
本篇主要讲述了UICollectionView的数据异步预加载,感兴趣的给个赞或者关注~~~