UITableView Prefetching

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, and indexPaths 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