In iOS 10 Apple has introduced prefetching API for UITableView
and UICollectionView
. In this article I’d like to cover what is it for and how to properly use prefetching for UITableView
.
What is UITableViewDataSourcePrefetching
Prefetching is useful when we need to download large models (usually images or video files) from the web or obtain from disk. Prefetching also gives us some kind of laziness in accessing data models: we don’t have to obtain all the data models, but rather only those, which are about to display. This approach reduces battery and CPU consuming and, thus, leads to better user experience.
Apple’s implementation of prefetching API exposes UITableViewDataSourcePrefetching
protocol with two methods:
public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath])
optional public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath])
and a new UITableView
’s property:
@available(iOS 10.0, *)
weak open var prefetchDataSource: UITableViewDataSourcePrefetching?
How prefetching works
To enable prefetching for our UITableView
we need to make our view controller conforming to UITableViewDataSourcePrefetching
protocol and set self
as a prefetchDataSource
for UITableView
:
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.prefetchDataSource = self
}
}
// MARK: - UITableViewDataSourcePrefetching
extension ViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
print("prefetchRowsAt \(indexPaths)")
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
print("cancelPrefetchingForRowsAt \(indexPaths)")
}
}
Now our controller is notified when to prefetch more data or cancel prefetching.
Here are my observations about prefetching:
- At inital load
tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath])
doesn’t get called for intially visible rows. - As soon as initially visible rows becomes visible,
tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath])
get’s called, andindexPaths
variable holds index paths of the next 10 rows. - When the user scrolls down, the method get’s called for the next row is about to display. Depending on the speed of scrolling
indexPaths
variable may hold one or more index paths:
- When the user suddenly changes scroll direction, the method get’s called with up to 10 next index paths:
tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath])
method gets called when the user changes scroll direction or when the user taps on the status bar, and the table view scrolls to the top rapidly.
Prefetching with UITableView: example of usage
Let’s get some practice with prefetching. I prepared a demo project demonstrating how to use prefetching with UITableView
. In short, it consists of a table view displaying images from the web. First, I defined a data model:
struct Model {
let urlString: String
lazy var url: URL = {
// I know it's unsafe.
return URL(string: self.urlString)!
}()
var image: UIImage?
init(urlString: String) {
self.urlString = urlString
}
}
and created an array of models for table view:
@IBOutlet weak var tableView: UITableView!
var items =
[Model(urlString: "http://www.gstatic.com/webp/gallery/1.jpg"),
Model(urlString: "http://www.gstatic.com/webp/gallery/2.jpg"),
Model(urlString: "http://www.gstatic.com/webp/gallery/3.jpg"),
Model(urlString: "http://www.gstatic.com/webp/gallery/4.jpg"),
Model(urlString: "http://www.gstatic.com/webp/gallery/5.jpg"),
Model(urlString: "http://imgsv.imaging.nikon.com/lineup/coolpix/a/a/img/sample/img_06_l.jpg"),
Model(urlString: "http://imgsv.imaging.nikon.com/lineup/coolpix/a/a/img/sample/img_07_l.jpg"),
Model(urlString: "http://imgsv.imaging.nikon.com/lineup/coolpix/a/a/img/sample/img_08_l.jpg"),
Model(urlString: "http://imgsv.imaging.nikon.com/lineup/coolpix/a/a/img/sample/img_09_l.jpg"),
Model(urlString: "http://imgsv.imaging.nikon.com/lineup/coolpix/a/a/img/sample/img_10_l.jpg"),
Model(urlString: "https://www.gstatic.com/webp/gallery3/1.png"),
Model(urlString: "https://www.gstatic.com/webp/gallery3/2.png"),
Model(urlString: "https://www.gstatic.com/webp/gallery3/3.png"),
Model(urlString: "https://www.gstatic.com/webp/gallery3/4.png"),
Model(urlString: "https://www.gstatic.com/webp/gallery3/5.png")]
I’d like to have downloading tasks to be cancellable, so we need to store ongoing tasks somewhere:
/// We store all ongoing tasks here to avoid duplicating tasks.
fileprivate var tasks = [URLSessionTask]()
Now we need to create two functions: one is for downloading an image, another is for canceling such download. Their implementations are pretty straightforward:
// MARK: - Image downloading
fileprivate func downloadImage(forItemAtIndex index: Int) {
let url = items[index].url
guard tasks.index(where: { $0.originalRequest?.url == url }) == nil else {
// We're already downloading the image.
return
}
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
// Perform UI changes only on main thread.
DispatchQueue.main.async {
if let data = data, let image = UIImage(data: data) {
self.items[index].image = image
// Reload cell with fade animation.
let indexPath = IndexPath(row: index, section: 0)
if self.tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false {
self.tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .fade)
}
}
}
}
task.resume()
tasks.append(task)
}
fileprivate func cancelDownloadingImage(forItemAtIndex index: Int) {
let url = items[index].url
// Find a task with given URL, cancel it and delete from `tasks` array.
guard let taskIndex = tasks.index(where: { $0.originalRequest?.url == url }) else {
return
}
let task = tasks[taskIndex]
task.cancel()
tasks.remove(at: taskIndex)
}
Next, populate UITableView
:
// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath)
if let imageView = cell.viewWithTag(100) as? UIImageView {
if let image = items[indexPath.row].image {
imageView.image = image
} else {
imageView.image = nil
self.downloadImage(forItemAtIndex: indexPath.row)
}
}
return cell
}
}
Note we start downloading an image only when needed. Another important thing: don’t forget to “nil-out” imageView.image
if there’s no image for the given model. Otherwise the cell may show unrelated image while the app is downloading the actual image!
The final part is implementing UITableViewDataSourcePrefetching
protocol. Again, nothing fancy here:
// MARK: - UITableViewDataSourcePrefetching
extension ViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
print("prefetchRowsAt \(indexPaths)")
indexPaths.forEach { self.downloadImage(forItemAtIndex: $0.row) }
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
print("cancelPrefetchingForRowsAt \(indexPaths)")
indexPaths.forEach { self.cancelDownloadingImage(forItemAtIndex: $0.row) }
}
}
Here we just call downloadImage(_:)
and cancelDownloadingImage(_:)
for each index paths.
WARNING: do not call tableView.reloadData()
or tableView.reloadRows(...)
from tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath])
method! These methods provoke UITableView
to call prefetchRowsAt...
and thus lead to infinity loop.
Github repo: https://github.com/agordeev/uitableview-prefetching