UICollectionView Tutorial: Prefetching APIs
In this UICollectionView prefetching tutorial, you’ll learn how to achieve smooth scrolling in your app using Operations and Prefetch APIs. By Christine Abernathy.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
UICollectionView Tutorial: Prefetching APIs
20 mins
As a developer, you should always strive to provide a great user experience. One way to do this in apps that display lists is to make sure scrolling is silky smooth. In iOS 10, Apple introduced UICollectionView
prefetching APIs, and corresponding UITableView
prefetching APIs, that allow you to fetch data before your Collection Views and Table Views need it.
When you come across an app with choppy scrolling, this is usually due to a long running process that’s blocking the main UI thread from updating. You want to keep the main thread free to respond to things like touch events. A user can forgive you if you take a tad long to fetch and display data, but they won’t be as forgiving if your app is not responding to their gestures. Moving heavy work to a background thread is a great first step in building a responsive app.
In this tutorial, you’ll start working with EmojiRater, an app that displays a collection of emojis. Unfortunately, its scrolling performance leaves a lot to be desired. You’ll use the prefetch APIs to find out which cells your app is likely to display soon and trigger related data fetches in the background.
Getting Started
Use the Download Materials button at the top or bottom of this tutorial to download the starter project. Build and run the app. You should see something like this as you try and scroll:
Painful, isn’t it? Does it remind you of the chalkboard experience? You know the one where… never mind. The good news is that you can fix this.
A little bit about the app. The app displays a collection view of emojis that you can downvote or upvote. To use, click one of the cells, then press firmly until you feel some haptic feedback. The rating selection should appear. Select one and see the result in the updated collection view:
Take a look at the project in Xcode. These are the main files:
- EmojiRating.swift: Model representing an emoji.
- DataStore.swift: Loads an emoji.
- EmojiViewCell.swift: Collection view cell that displays an emoji.
- RatingOverlayView.swift: View that allows the user to rate an emoji.
- EmojiViewController.swift: Displays the emojis in a collection view.
You’ll add functionality to DataStore
and EmojiViewController
to enhance the scroll performance.
Understanding Choppy Scrolling
You can achieve smooth scrolling by making sure your app meets the 60 frames per second (FPS) display constraint. This means that your app needs to be able to refresh its UI 60 times a second, so each frame has about 16ms to render content. The system drops frames that takes too long to show content.
This results in a choppy scrolling experience as the app skips the frame and moves onto the next frame. A possible reason for a dropped frame is a long-running operation that’s blocking the main thread.
Apple has provided some handy tools to help you out. Firstly, you can split out your long-running operations and move them to a background thread. This allows you to handle any touch events, as they happen, on the main thread. When the background operation completes, you can make any required UI updates, based on the operation, on the main thread.
The following shows the dropped frame scenario:
Once you move work to the background, things look like this:
You now have two concurrent threads running to improve your app’s performance.
Wouldn’t it be even better if you could start fetching data before you had to display it? That’s where the UITableView
and UICollectionView
prefetching APIs come in. You’ll use the collection view APIs in this tutorial.
Loading Data Asynchronously
Apple provides a number of ways to add concurrency to your app. You can use Grand Central Dispatch (GCD) as a lightweight mechanism to execute tasks concurrently. Or, you can use Operation, which is built on top of GCD.
Operation
adds more overhead but makes it easy to reuse and cancel operations. You’ll use Operation
in this tutorial so that you can cancel an operation that previously started loading an emoji that you no longer need.
It’s time to start investigating where you can best leverage concurrency in EmojiRater.
Open EmojiViewController.swift and find the data source method collectionView(_:cellForItemAt:)
. Look at the following code:
if let emojiRating = dataStore.loadEmojiRating(at: indexPath.item) {
cell.updateAppearanceFor(emojiRating, animated: true)
}
This loads the emoji from the data store before displaying it. Let’s find out how that is implemented.
Open DataStore.swift and take a look at the loading method:
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
}
This code returns a valid emoji after a random delay that can range from 500ms to 2,000ms. The delay is an artificial simulation of a network request under varying conditions.
Culprit uncovered! The emoji fetch is happening on the main thread and is violating the 16ms threshold, triggering dropped frames. You're about to fix this.
Add the following code to the end of 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
is an abstract class that you must subclass to implement the work you want to move off the main thread.
Here's what's happening in the code step-by-step:
- Create a reference to the emoji and completion handler that you'll use in this operation.
- Create a designated initializer allowing you to pass in an emoji.
- Override the
main()
method to perform the actual work for this operation.
Now, add the following code to 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)
}
}
Going through the code step-by-step:
- Check for cancellation before starting. Operations should regularly check if they have been cancelled before attempting long or intensive work.
- Simulate the long-running emoji fetch. This code should look familiar.
- Check to see if the operation has been cancelled.
- Assign the emoji to indicate that the fetch has completed.
- Call the completion handler on the main thread, passing in the emoji. This should then trigger a UI update to display the emoji.
Replace loadEmojiRating(at:)
with the following:
public func loadEmojiRating(at index: Int) -> DataLoadOperation? {
if (0..<emojiRatings.count).contains(index) {
return DataLoadOperation(emojiRatings[index])
}
return .none
}
There are two changes from the original code:
- You create a
DataLoadOperation()
to fetch the emoji in the background. - This method now returns a
DataLoadOperation
optional instead anEmojiRating
optional.
You now need to take care of the method signature change and make use of your brand new operation.
Open EmojiViewController.swift and, in collectionView(_:cellForItemAt:)
, delete the following code:
if let emojiRating = dataStore.loadEmojiRating(at: indexPath.item) {
cell.updateAppearanceFor(emojiRating, animated: true)
}
You will no longer kick off the data fetch from this data source method. Instead, you will do this in the delegate method that's called when your app is about to display a collection view cell. Don't get ahead of yourself yet...
Add the following properties near the top of the class:
let loadingQueue = OperationQueue()
var loadingOperations: [IndexPath: DataLoadOperation] = [:]
The first property holds the queue of operations. loadingOperations
is an array that tracks a data load operation, associating each loading operation with its corresponding cell via its index path.
Add the following code to the end of the file:
// 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
}
}
}
}
This creates an extension for UICollectionViewDelegate
and implements the collectionView(_: willDisplay:forItemAt:)
delegate method. Going through the method step-by-step:
- Create a closure to handle how the cell is updated once the data is loaded.
- Check if there's a data-loading operation for the cell.
- Check if the data-loading operation has completed. If so, update the cell's UI and remove the operation from the tracking array.
- Assign the closure to the data-loading completion handler if the emoji has not been fetched.
- In the event that there's no data loading operation, create a new one for the relevant emoji.
- Add the closure to the data-loading completion handler.
- Add the operation to your operation queue.
- Add the data loader to the operation-tracking array.
You'll want to make sure you do some cleanup when a cell is removed from the collection view.
Add the following method to the UICollectionViewDelegate
extension:
override func collectionView(_ collectionView: UICollectionView,
didEndDisplaying cell: UICollectionViewCell,
forItemAt indexPath: IndexPath) {
if let dataLoader = loadingOperations[indexPath] {
dataLoader.cancel()
loadingOperations.removeValue(forKey: indexPath)
}
}
This code checks for an existing data-loading operation that's tied to the cell. If one exists, it cancels the download and removes the operation from the array that tracks operations.
Build and run the app. Scroll through the emojis and note the improvement in the app's performance.
If you could optimistically fetch the data in anticipation of a collection view cell being displayed, that would be even better. You'll use the prefetch APIs to do this and give EmojiRater an extra boost.