Swift networking with Combine

Combine was introduced at WWDC 2019

Posted on by Ciprian Redinciuc Ciprian Redinciuc · 4 min read

Introducing Combine

Apple announced the introduction of a new framework called Combine in 2019 and I have to be honest but I haven't had the opportunity to test it out until now.

With the advances in SwiftUI presented at WWDC2020 it seems to me that Combine and SwiftUI are a match made in heaven and if you still wonder if you should start using these new frameworks I think you should just start using them right away.

Combine is a framework that helps us write functional reactive code. It allows us to process values emitted by a publisher over time. It is much like RxSwift or ReactiveCocoa but supported out of the box by Apple.

In iOS, we deal with a lot of asynchronous operations such as network requests, fetching data from the local storage, user interface updates and so on and Combine helps us bridge the elements that emit values with the ones that are interested in those updates.

Publishers, Operators and Subscribers

There are three key concepts in Combine that we need to understand:

Filtering events emitted by a publisher

  • Publishers
    A publisher emits elements to one or more Subscriber instances - a UITextField changing its contents as the user types his password.
  • Operators
    Operators are higher order functions that help us manipulate the elements emitted by a Publisher - verifying if the text represents a secure password.
  • Subscribers
    Subscribers are instances that are interested in elements emitted by a Publisher - disabling the Sign up button if the password is weak.

I won't go into any further details but I recommend reading more about Combine here.

URLSessionDataPublisher

The first addition Apple showcased at WWDC was probably one of the most important for iOS developers: the addition of Combine support to URLSession via a URLSessionDataPublisher.

Creating a URLSessionDataPublisher is straightforward:

let url = URL(string: "https://api.twitter.com/1.1/statuses/user_timeline?screen_name=cyupa89")!
let publisher = URLSession.shared.dataTaskPublisher(for: url)

As we mentioned earlier, to be able to receive values from a publisher, we need to create a subscription to it and Combine provides one subscriber out of the box via the sink method.

let cancelToken = publisher.sink(
    receiveCompletion: { completion in
        // Will be called once, when the publisher has completed.
        // The completion itself can either be successful, or not.
        switch completion {
          case .failure(let error):
            print(error)
          case .finished:
            print("Finished successfuly")
        }
    },
    receiveValue: { value in
        // Will be called each time a new value is received.
        // In our case these should be a set of tweets.
        print(value)
    }
)

You can always stop receiving values by either deallocating the cancelToken or by calling cancelToken.cancel().

Mapping responses with Codable

One huge advantage that combine offers thanks to its operators it that it allows us to easily transform a JSON response into one of our model representations.

Thanks to operators such as map or tryMap and decode you can streamline fetching your data with just a couple lines of code:

let tweetsPublisher = publisher.map(\.data)
                               .decode(type: Tweet.self, decoder: JSONDecoder())
                               .receive(on: DispatchQueue.main)

The map and tryMap operators of the DataTaskPublisher offers a closure that has two parameters: data, representing a Data instance and a URLResponse instance called response.

We return the data instance from that closure and pass it on to the decode operator that takes a type and a decoder to transform the fetched data into a model instance.

Last but not least, we want to return this instance on the main queue using the receive operator.

While this may be a good enough solution to fetching some data, I think it can be improved thanks to other operators available:

struct HTTPResponse {
    let value: Tweet?
    let response: URLResponse
}

let tweetsPublisher = publisher.retry(3)
                               .tryMap { result -> HTTPResponse in
                                  let tweet = try decoder.decode(Tweet.self, from: result.data)                                  
                                  return HTTPResponse(value: tweet, response: result.response)
                               }
                               .receive(on: DispatchQueue.main)
                               .eraseToAnyPublisher()

By encapsulating the response in a HTTPResponse we can build a more granular approach to determine why an operation has failed, for example, discerning between unauthorized access and bad requests.

As we know, requests might fail due to poor network conditions, and Combine offers us a simple yet effective way of retrying requests thanks to the retry operator.

The last bit, .eraseToAnyPublisher() is used to hide the publisher type to the caller and expose it as an AnyPublisher type. This way, we could change our internal implementation without affecting any of our callers.

Make it generic

It wouldn't be a useful example for making network requests if it wasn't one that allows us to apply the same principles regarding the types fetched.

To make this implementation generic, we will build on top of the example above:

struct HTTPResponse<T> {
    let value: T
    let response: URLResponse
}

struct HTTPClient {
    let session: URLSession

    func perform<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<HTTPResponse<T>, Error> {
        return session.dataTaskPublisher(for: request)
            .retry(3)
            .tryMap { result -> HTTPResponse<T> in
                let tweet = try decoder.decode(T, from: result.data)                                  
                return HTTPResponse(value: tweet, response: result.response)
            }
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

This way, we can handle any type of expected data from our server and, if you are interested into how you can structure your calls by separating them into endpoints and build requests in a structured manner, check this article.

Then, you could be making requests by writing something along the lines:

struct TwitterAPI {
  let client: HTTPClient
  
  func getTweetsFor(screenName: String) -> AnyPublisher<Tweet, Error> {
    let request = TimelineEndpoint.getFor(screenName: screenName).urlRequest
    return client.perform(request)
                  .map(\.value)
                  .eraseToAnyPublisher()
  }
}

Want products news and updates?

Sign up for our newsletter to stay up to date.

We care about the protection of your data. Read our Privacy Policy.

© 2020 Applicodo SRL. All rights reserved.