Articles, podcasts and news about Swift development, by John Sundell.

Publishing constant values using Combine

Published on 15 Oct 2020
Discover page available: Combine

Although Combine is definitely the most useful when it comes to building highly dynamic data pipelines for publishing streams of values over time, sometimes we might also want to use it to emit constant values as well.

As an example, let’s say that we’re working on an app that includes an ImageLoader class for downloading remote images over the network. To do that, we’re using the Combine version of Foundation’s URLSession API, along with a series of operators for decoding our downloaded data into UIImage instances — like this:

class ImageLoader {
    private let urlSession: URLSession

    init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }

    func publisher(for url: URL) -> AnyPublisher<UIImage, Error> {
        urlSession.dataTaskPublisher(for: url)
            .map(\.data)
            .tryMap { data in
                guard let image = UIImage(data: data) else {
                    throw URLError(.badServerResponse, userInfo: [
                        NSURLErrorFailingURLErrorKey: url
                    ])
                }

                return image
            }
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

Note how we’re jumping over to the main queue at the end of our Combine pipeline, since the images downloaded using our ImageLoader are highly likely to be rendered using either a UIImageView or a SwiftUI Image, both of which are main-queue-only APIs.

Now, let’s say that we wanted to add local, in-memory caching to the above ImageLoader, for example using another Foundation class — NSCache:

class ImageLoader {
    private let urlSession: URLSession
    private let cache: NSCache<NSURL, UIImage>

    init(urlSession: URLSession = .shared,
         cache: NSCache<NSURL, UIImage> = .init()) {
        self.urlSession = urlSession
        self.cache = cache
    }

    ...
}

We need to use Objective-C’s NSURL, rather than Swift’s own URL, as our cache’s key type, since NSCache only supports Objective-C compatible types. To learn more about caching in general, check out “Caching in Swift”.

With our cache injected and stored, we now need to do two things — cache all images that were downloaded, and check the cache for an existing image before starting a given download. The first can be accomplished by using the handleEvents operator at the end of our existing Combine pipeline — like this:

class ImageLoader {
    ...

    func publisher(for url: URL) -> AnyPublisher<UIImage, Error> {
        urlSession.dataTaskPublisher(for: url)
            .map(\.data)
            .tryMap { data in
                ...
            }
            .receive(on: DispatchQueue.main)
            .handleEvents(receiveOutput: { [cache] image in
                cache.setObject(image, forKey: url as NSURL)
            })
            .eraseToAnyPublisher()
    }
}

For the second task (retrieving images from the cache), we’re going to need a separate pipeline from the one used for downloading, since we don’t wish to kick off any network calls if a cached image exists.

This is where Combine’s constant value API comes very much in handy, since it enables us to construct a pipeline with just a single value — using the appropriately named Just publisher. Here’s how we might use that publisher to directly return any cached image that was found for a given URL:

class ImageLoader {
    ...

    func publisher(for url: URL) -> AnyPublisher<UIImage, Error> {
        if let image = cache.object(forKey: url as NSURL) {
            return Just(image)
                .setFailureType(to: Error.self)
                .receive(on: DispatchQueue.main)
                .eraseToAnyPublisher()
        }

        ...
    }
}

Note how we need to explicitly set Error as our Just publisher’s failure type, since constant publishers aren’t capable of emitting errors by default. We also still want to always return our result on the main queue, even for constant values, to give our API complete consistency around what DispatchQueue that it emits values on.

Finally, Combine also offers a way to emit constant errors as well, by using the built-in Fail publisher. Here’s an example showing how we might use that API to directly return an error in case an image with a non-HTTPS URL was requested:

class ImageLoader {
    ...

    func publisher(for url: URL) -> AnyPublisher<UIImage, Error> {
        guard url.scheme == "https" else {
            return Fail(error: URLError(.badURL, userInfo: [
                NSLocalizedFailureReasonErrorKey: """
                Image loading may only be performed over HTTPS
                """,
                NSURLErrorFailingURLErrorKey: url
            ]))
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
        }

        ...
    }
}

The Just and Fail publishers are also incredibly useful for unit testing as well, as they enable us to easily set up a Combine pipeline with either a mocked value or error.