UIKit框架(九) —— UICollectionView的数据异步预加载(一)

版本记录

版本号 时间
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”功能的触控板的MacMacBook。 然后,您可以转到System Preferences ▸ Trackpad并启用Force Click and haptic feedback。 如果您无法访问此类设备或使用3D Touch的iPhone,您仍然可以获得本教程的基本知识。

看看Xcode中的项目。 这些是主要文件:

  • EmojiRating.swift:表示表情符号的模型。
  • DataStore.swift:加载一个表情符号。
  • EmojiViewCell.swift:显示表情符号的集合视图单元格。
  • RatingOverlayView.swift:允许用户对表情符号进行评级的视图。
  • EmojiViewController.swift:在集合视图中显示表情符号。

您将向DataStoreEmojiViewController添加功能以增强滚动性能。


Understanding Choppy Scrolling - 了解断续的滚动

您可以通过确保您的应用程序满足每秒60帧(FPS)显示约束来实现平滑滚动。 这意味着您的应用程序需要能够每秒刷新其UI 60次,因此每个帧大约需要16毫秒来呈现内容。 系统会丢弃需要太长时间才能显示内容的帧。

当应用程序跳过帧并移动到下一帧时,这会导致不稳定的滚动体验。 丢帧的可能原因是长时间运行阻塞主线程的操作。

Apple提供了一些方便的工具来帮助您。 首先,您可以拆分长时间运行的操作并将它们移动到后台线程。 这允许您在主线程上处理任何触摸事件。 后台操作完成后,您可以根据操作在主线程上进行任何所需的UI更新。

以下显示了丢帧的情况:

将工作移至后台后,事情如下所示:

您现在有两个并发线程正在运行以提高应用程序的性能。

如果你可以在必须显示之前开始获取数据,那会不会更好? 这就是UITableViewUICollectionView预取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的数据异步预加载,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容