Apple Combine Intro PART II

Apple Combine Intro PART II

Let’s put together everything we learned in Part I of this article by developing an app for loading random cat images from a web service. We want to focus only on Combine features and avoid much UI work. Therefore, we will write the code in Playground, with a very basic UI, just for demonstrating that the Combine pipe works.

The app will use this service for loading random cat images.

To obtain an image we should follow a couple simple steps:

  1. Request the image URL from https://api.thecatapi.com
  2. Download the image.

At the time of writing this article, the TheCatAPI is under development. So, some of the API details may change by the time you are reading this article.

You can download the full playground implementation from our repository.

It’s time to code!

First, create a Playground. Select “iOS” template and “Single View” playground.

Apple Combine Intro

Apple Combine Intro

Xcode will generate a playground file with a simple ViewController and some support code for rendering the view controller.

Lets make some changes to the ViewController. We need a UIImageView (for showing the loaded image) and a label (for showing status messages, like network errors, etc.).

class MyViewController : UIViewController {
    
    var imageView: UIImageView!
    var label: UILabel!
    
    override func loadView() {
        let view = UIView()
        view.backgroundColor = .white

        imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.frame = CGRect(x: 50, y: 200, width: 200, height: 200)
        
        label = UILabel()
        label.frame = CGRect(x: 50, y: imageView.frame.maxY + 15, width: 200, height: 45)
        label.textAlignment = .center

        view.addSubview(imageView)
        view.addSubview(label)
        
        self.view = view
    }
}

Also we want them to be centered horizontally

override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        imageView.center = CGPoint(x: view.bounds.midX,
                                                       y: imageView.frame.minY + imageView.frame.height / 2)
        label.center = CGPoint(x: view.bounds.midX,
                                             y: label.frame.minY + label.frame.height / 2)
        
        view.layoutIfNeeded()
    }

Now we need to build a pipe that takes care of loading an image with cats ^_^ As it was mentioned, the whole process will take 2 steps, so we need two pipes for each: one for requesting the image URL and one for loading an image from that URL. Let’s make separate functions which will return a pipe for each step: loadImageUrl and loadImageData. Later, we want to chain those two pipes, so the pipe returned by loadImageData will use the output of the pipe returned by loadImageUrl. And of course, at some place in our app we want to use the output of the pipe returned by loadImageData to show the image on UI.

One important concussion: we want our pipes to be able to emit values (image URL for the first, and Image Data from the second). Such pipes are also Publishers, because they emit values.

Image URL request pipe

Start with loadImageUrl() -> AnyPublisher<URL, MeowError>.  It will take care of requesting the image URL. The return type is a Publisher with Output as URL and Failure as MeowError. Yes, we want our custom error type, so let’s create one.

enum MeowError: Error {
    case networkError(error: URLError) /* like timeouts, unreachable,etc) */
    case badServerResponse(response: URLResponse) /* if the HTTP response is not 200 */
    case badServerResponseData(coderError: Error) /* if we can’t serialize the response */
}

Combine ads to URLSession a special publisher dataTaskPublisher which will actually make the request. DataTaskPublisher defined as

public struct DataTaskPublisher : Publisher {
        public typealias Output = (data: Data, response: URLResponse)
        public typealias Failure = URLError

So lets create a urlSession instance of URLSession and start the request with the publisher.

urlSession.dataTaskPublisher(for: apiUrl)
.print()

The print() Operator prints debug messages to the console.

apiUrl is defined as

let apiUrl = URL(string: "https://api.thecatapi.com/v1/images/search")!

dataTaskPulisher fires when the request is done and we want to check if there are no URL errors and the response status code is 200. Otherwise we want to convert URL error to MeowError, or generate a MeowError if the response status code is not 200.

.mapError { (urlError: URLError) -> CatRepositoryError in
     CatRepositoryError.networkError(error: urlError)
}
 .tryMap {
     if ($0.response as! HTTPURLResponse).statusCode != 200 {
           throw CatRepositoryError.badServerResponse(response: $0.response)
     }
     return $0.data
}

Please note:

  • in case a publisher emits an error, only error operators will be called, till the emitted error reaches the Subscriber (e.g. end of the pipe), or some error operator convert the error value to an Input value of the next operator/receiver. In the example above, if dataTaskPublisher emits an error, the mapError operator will be called, but tryMap will not.
  • Every Combine Operator with “try” prefix can emit an Error, with Swifts “throw” keyword.

Next step would be to decode the returned JSON data, and handle any JSON decoding errors by converting it to MeowError.

Here is one example of the returned JSON struct in the get URL response:

[{"breeds":[],"id":"acc","url":"https://cdn2.thecatapi.com/images/acc.jpg","width":500,"height":522}]

We define a Swift structure for representing the json list item, and decode only the interested fields, which is the “url” in our case:

struct MeowResponse: Codable {
    let url: String
    
    private enum CodingKeys: String, CodingKey {
        case url
    }
}

CodingKeys enum will tell the Coder which fields we want to decode (for more details see apple.com)

.decode(type: [MeowResponse].self, decoder: JSONDecoder())
.mapError { (error) -> MeowError in
                if let meowError = error as? MeowError {
                    return meowError
                }
                return MeowError.badServerResponseData(coderError: error)
            }

We need to be aware that mapError will be also called when some Operator above throwed or mapped an error. So, if the operator input is not a MeowError, it should be an error from the JSON Decoder.

Now we can extract the url from the serialized JSON response. Remember, we are getting a list of MeowReqponse structures from the REST API. We expect that the list will contain at least one structure, so:

.map { (response: [MeowResponse]) -> URL in
     URL(string:response.first!.url)!
}

And finally put the code snippets together into the loadImageUrl function:

func loadImageUrl() -> AnyPublisher<Data, MeowError> {
        if urlSession == nil {
            urlSession = URLSession(configuration: .default)
        }
        
        return urlSession!
            .dataTaskPublisher(for: apiUrl)
            .print()
            .mapError { (urlError: URLError) -> MeowError in
                MeowError.networkError(error: urlError)
            }
            .tryMap {
                if ($0.response as! HTTPURLResponse).statusCode != 200 {
                    throw MeowError.badServerResponse(response: $0.response)
                }
                return $0.data
            }
            .decode(type: [MeowResponse].self, decoder: JSONDecoder())
            .mapError { (error) -> MeowError in
                if let meowError = error as? MeowError {
                    return meowError
                }
                return MeowError.badServerResponseData(coderError: error)
            }
            .map { (response: [MeowResponse]) -> URL in
                URL(string:response.first!.url)!
            }
.eraseToAnyPublisher()
  }

To make this method work, we need to create a urlSession variable of type URLSession in the ViewController.

Probably you noticed the use of eraseToAnyPublisher() at the end of the pipe. This operator is needed to simplify the resulting Publisher type. To understand what we are talking about, just delete the eraseToAnyPublisher() operator and study the error message.

Finally, the pipe for requesting the image URL is ready!

Load image data pipe

The load image data pipe is similar to the request image URL pipe, except that we are not serializing the response data.

func loadImageData(url: URL) -> AnyPublisher<Data, MeowError> {
        urlSession!
            .dataTaskPublisher(for: url)
            .mapError { (urlError: URLError) -> MeowError in
                return MeowError.networkError(error: urlError)
            }
            .tryMap { comp -> Data in
                if (comp.response as! HTTPURLResponse).statusCode != 200 {
                    throw MeowError.badServerResponse(response: comp.response)
                }
                return comp.data
            }
            .mapError{
                $0 as! MeowError
            }
            .eraseToAnyPublisher()
    }

One point which needs to be discussed is

.mapError{
     $0 as! MeowError
}

Why do we need this operator at the end? Didn’t we map any possible error types to MeowError? Yes we did, but…  Even we emit a MeowError In the tryMap operator, the tryMap.Failure defined as Error. So, the next Operator/Receiver in the chain will automatically expect a general Error Failure type in its input.

.tryMap { comp -> Data in
     if (comp.response as! HTTPURLResponse).statusCode != 200 {
          throw MeowError.badServerResponse(response: comp.response)
….

But we are 100% sure that after tryMap operator we will deal with MeowError (because we already converted any possible errors to MeowError and in tryMap Operator we also emit  MeowError). So, it is safe to downcast the error type to MeowError in the mapError Operator.

Connect pipes

Now we want to connect the two pipes together, to make One complex pipe which is able to emit values, so, it is a Publisher in fact. In Combine, we can connect two publishers together by the flatMap Operator.

Let’s connect loadImageData to loadImageUrl

func loadAll() -> AnyPublisher<Data, MeowError> {
        loadImageUrl().flatMap {
            self.loadImageData(url: $0)
        }.eraseToAnyPublisher()
    }

To complete our example, add the following code snippet to the end of the viewDidLoad() method (which we would not do in production, but remember, this is just a study example).

cancellable = loadAll()
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { (completion: Subscribers.Completion) in
                switch completion {
                case .finished:
                    self.label.text = "Success"
                case .failure(let error):
                    switch error {
                    case .badServerResponse:
                        self.label.text = "Bad response"
                    case .badServerResponseData:
                        self.label.text = "Bad response data"
                    case .networkError:
                        self.label.text = "Network error"
                    }
                }
            
            }) { (imageData) in
                self.imageView?.image = UIImage(data: imageData)
            }

Cancelable declared in ViewController as

var cancelable: AnyCancellable!

We need to store cancelable for 2 reasons:

  • To be able to cancel the pipe.
  • To retain it. Otherwise the pipe will be destroyed as soon as the viewDidLoad() method is finished.

You should have already noticed that we used a new operator, receive(on:). In Combine, the next operator called in the caller’s thread. And because URLSessions dataTaskPublisher emit event values on a non main thread, all the next operators are called in that thread. But at the end of the pipe we want to modify some UI components, which should happen on main thread only. Therefore we tell Combine to continue the pipe execution on a specific thread by receive(on:) Operator.

Execute the playground and wait a few seconds. We are expecting to see the loaded image. Or an error message in case of network issues, bad server response status code or wrong JSON format in the response.

You can play with the code, for example, create a load button (to start the pipe manually) and a cancel button (just to be sure we are able to cancel the pipe, and with the cancelled pipe – any network request).

Conclusion

Using Combine, we’ve managed to develop a pipe which interacts with a rest API in few steps. We learned some new operators: eraseToAnyPublisher and receive(on:). We learned that a pipe which emits values is actually a Publisher. Also we clarified that the operators execution order in the pipe in case an error value emitted.

As you see we wrote a nice, declarative and extremely small code for such an API interaction. And  thanks to Combine, it also has a comprehensive, and still simple error handling.

As a bonus, for using Combine, you get all the cancel functionality for free! (just imagine how much more code it would take to write such an app without reactive programming).