Downloading, Caching & Decoding Images Asynchronously with Alamofire: Part 1

Goal: Download and cache images asynchronously for use in a UICollectionView.

Downloading and caching images are common tasks in iOS development, especially when using collection and table views. In this tutorial, we're going to use the popular Swift networking library Alamofire and its companion image library AlamofireImage to build an app that displays images of Glacier National Park. The app is called "Glacier Scenics" and uses images from the National Park Service which can be found here.

The first part of the tutorial will cover asynchronously downloading and caching images. The second part will add an asynchronous decoding step that will further improve scrolling performance.

Here's what we'll be building:

The next section will go over the setup details of the project, data, and collection view. If you're just interested in the downloading and caching of images you can skip this section.

Setup

Dependencies

I'm using CocoaPods to pull the Alamofire and AlamofireImage dependencies. Here's this project's Podfile:

platform :ios, '9.0'
use_frameworks!

target 'GlacierScenics' do
  pod 'Alamofire', '~> 4.0'
  pod 'AlamofireImage', '~> 3.0'
end

Data

The image names and URLs are taken from a property list, which is just an array of dictionaries with two keys: "name" and "imageURL".

Collection View Controller

The collection view controller is set up in Storyboard using the basic template embedded in a navigation controller. The only changes were setting the minimum spacing in the default flow layout to 1 for both cells and lines and setting the class to PhotosCollectionViewController (described below).

Collection View Cell

The collection view cell uses a separate Xib file with the following views:

Asynchronously Downloading Images

The first step is to create our model, which in this case is a simple struct called Photo. We'll be reading from a property list that contains an array of dictionaries, so we'll give Photo an initializer that takes a dictionary.

struct Photo {

    let name: String
    let url: String

    init(info: [String: Any]) {
        self.name = info["name"] as! String
        self.url = info["imageURL"] as! String
    }

}

Next we create a manager class to read the property list and store the photo information.

import UIKit

class PhotosManager {

    static let shared = PhotosManager()

    private var dataPath: String {
        return Bundle.main.path(forResource: "GlacierScenics", ofType: "plist")!
    }

    lazy var photos: [Photo] = {
        var photos = [Photo]()
        guard let data = NSArray(contentsOfFile: self.dataPath) as? [[String: Any]] else { return photos }
        for info in data {
            let photo = Photo(info: info)
            photos.append(photo)
        }
        return photos
    }()

}

Note that the array of photos is a lazy var so we only read from the plist once.

Next let's look at our collection view controller code.

import UIKit

private let photoCellIdentifier = "PhotoCell"

class PhotosCollectionViewController: UICollectionViewController {

    var photosManager: PhotosManager { return .shared }

    override var prefersStatusBarHidden: Bool {
        return true
    }

    //MARK: - View Controller Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewCells()
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        collectionView?.collectionViewLayout.invalidateLayout()
    }

    //MARK: - Collection View Setup

    func registerCollectionViewCells() {
        let nib = UINib(nibName: "PhotoCollectionViewCell", bundle: nil)
        collectionView?.register(nib, forCellWithReuseIdentifier: photoCellIdentifier)
    }

    // MARK: - UICollectionViewDataSource

    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return photosManager.photos.count
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: photoCellIdentifier, for: indexPath) as! PhotoCollectionViewCell
        cell.configure(with: photo(at: indexPath))
        return cell
    }

    func photo(at indexPath: IndexPath) -> Photo {
        let photos = photosManager.photos
        return photos[indexPath.row]
    }

}

This is all pretty straightforward setup for a collection view and data source. One thing to note is that we're supporting landscape orientations, so in "viewWillTransitionToSize" we invalidate the collection view layout.

Speaking of the collection view layout, let's add an extension here for our implementation of UICollectionViewDelegateFlowLayout.

extension PhotosCollectionViewController: UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let viewSize = view.bounds.size
        let spacing: CGFloat = 0.5
        let width = (viewSize.width / 2) - spacing
        let height = (viewSize.width / 3) - spacing
        return CGSize(width: width, height: height)
    }

}

The sizing here gives us two collection view cells that span the width of the screen in either portrait or landscape orientation.

Now that we have our basic collection view setup, it's time to configure the cells and asynchronously download the images. We'll do that in the collection view cell subclass.

import UIKit
import Alamofire

class PhotoCollectionViewCell: UICollectionViewCell {

    var photosManager: PhotosManager { return .shared }

    @IBOutlet var imageView: UIImageView!
    @IBOutlet var captionLabel: UILabel!
    @IBOutlet var loadingIndicator: UIActivityIndicatorView!
    
    var request: Request?
    var photo: Photo!

    func configure(with photo: Photo) {
        self.photo = photo
        reset()
        loadImage()
    }

    func reset() {
        imageView.image = nil
        request?.cancel()
        captionLabel.isHidden = true
    }

    func loadImage() {
        loadingIndicator.startAnimating()
        request = photosManager.retrieveImage(for: photo.url) { image in
            self.populate(with: image)
        }
    }

    func populate(with image: UIImage) {
        loadingIndicator.stopAnimating()
        imageView.image = image
        captionLabel.text = photo.name
        captionLabel.isHidden = false
    }

}

First thing to note here is that we have a "request" variable. Since collection view cells are reused, we need to make sure we are loading the correct image for each cell as we scroll. If the request is still in-flight when the cell is reused, the cell could be populated with the wrong image when the request returns. Before we load the image then, we need to reset the cell by setting the cell's current image to nil, canceling an in-flight request if one exists, and hiding the caption label while we load the new image.

This class makes a call on our shared photos manager to "retrieveImage", which we haven't defined yet. This function will use Alamofire to actually download the image. So let's add that now in PhotosManager below our "photos" variable declaration.

//MARK: - Image Downloading

func retrieveImage(for url: String, completion: @escaping (UIImage) -> Void) -> Request {
    return Alamofire.request(url, method: .get).responseImage { response in
        guard let image = response.result.value else { return }
        completion(image)
    }
}

You'll also need to import the Alamofire libraries at the top of the file.

import Alamofire
import AlamofireImage

Here we use AlamofireImage's convenient image response serializer and call the completion block with the returned image if it exists. Now look back at where we call "retrieveImage" in our collection view cell's "loadImage" function. Notice that we store the returned request so that it can be canceled later in "reset" if necessary. In the completion block, we populate the cell by setting the image and caption text.

NOTE: The National Park Service website is not using HTTPS, which App Transport Security enforces. For this project, I enabled "Allow Arbitrary Loads" in the Info.plist "App Transport Security Settings". If you're following along with sample image URLs you may need to do this, but it is not a good practice to enable this in a production app.

Caching

With our current implementation, a network request is made for each cell whenever we scroll the collection view and "cellForItemAt:" is called. To avoid this, we'll use AlamofireImage to implement caching. AlamofireImage has a class called AutoPurgingImageCache, which allows us to set a maximum cache size as well as a preferred size to cut down to when the maximum is reached. Let's add in this caching support to our PhotosManager class.

First we'll add an "imageCache" property above the "Image Downloading" section.

let imageCache = AutoPurgingImageCache(
    memoryCapacity: UInt64(100).megabytes(),
    preferredMemoryUsageAfterPurge: UInt64(60).megabytes()
)

Here I'm also using a simple extension on UInt64 that converts megabytes to bytes. You can place this at the top of the file above the class declaration for PhotosManager.

extension UInt64 {

    func megabytes() -> UInt64 {
        return self * 1024 * 1024
    }

}

The cache is set to have a maximum capacity of 100MB and a preferred memory usage once the limit is reached of 60MB. Next let's update our "retrieveImage" function and add the caching functions.

func retrieveImage(for url: String, completion: @escaping (UIImage) -> Void) -> Request {
    return Alamofire.request(url, method: .get).responseImage { response in
        guard let image = response.result.value else { return }
        completion(image)
        self.cache(image, for: url)
    }
}

//MARK: = Image Caching

func cache(_ image: Image, for url: String) {
    imageCache.add(image, withIdentifier: url)
}

func cachedImage(for url: String) -> Image? {
    return imageCache.image(withIdentifier: url)
}

For the cache identifier we're using the image URL, and we cache the image in the "retrieveImage" function when the request returns. Now in our collection view cell, we just need to check if we have a cached image before downloading one. Replace "loadImage" with the following:

func loadImage() {
    if let image = photosManager.cachedImage(for: photo.url) {
        populate(with: image)
        return
    }
    downloadImage()
}

func downloadImage() {
    loadingIndicator.startAnimating()
    request = photosManager.retrieveImage(for: photo.url) { image in
        self.populate(with: image)
    }
}

We've changed our "loadImage" function to first check if the image has already been cached. If it has, we use it to populate the cell. If it hasn't, we call a new function called "downloadImage" to get the image from the network as we did before.

As you can see, Alamofire and AlamofireImage make it really easy to implement asynchronous downloading and caching of images, and the libraries have a lot of additional functionality for downloading and working with images. However, UIImage by default waits until the last minute to decode images before displaying them, and it does the decoding synchronously. This can cause performance issues when scrolling, especially with large images like we have in this project. Part 2 of this tutorial will focus on fixing that problem by decoding images asynchronously.

The source code for this project is available in the "GlacierScenicsAlamofire" folder here.