technicalJune 8, 2021

Swift networking with Combine

Article presentation
Combine framework helps you write functional reactive code

Introducing Combine

In 2019 Apple announced the introduction of a new framework called Combine.

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 question whether or not you should start using these frameworks, the answer is just to do it.

Combine is the framework that helps you write functional reactive code. It allows you 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, Combine helps us bridge the elements that emit values with the ones that are interested in those updates.

Publishers, Operators and Subscribers

These are the 3 key concepts in Combine that you need to understand:


combine-framework
  1. Publishers
    A publisher emits elements to one or more Subscriber instances - a UITextField changing its contents as the user types his password.
  2. Operator
    Operators are higher order functions that help us manipulate the elements emitted by a Publisher - verifying if the text represents a secure password.
  3. 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 now but you can always read 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:

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

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

 1 let cancelToken = publisher.sink(
 2     receiveCompletion: { completion in
 3         // Will be called once, when the publisher has completed.
 4         // The completion itself can either be successful, or not.
 5         switch completion {
 6           case .failure(let error):
 7             print(error)
 8           case .finished:
 9             print("Finished successfuly")
10         }
11     },
12     receiveValue: { value in
13         // Will be called each time a new value is received.
14         // In our case these should be a set of tweets.
15         print(value)
16     }
17 )

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

Mapping responses with Codable

Thanks to its operators, one huge advantage that Combine offers 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:

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


The map and tryMap operators of the DataTaskPublisher offer 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.

Finally, 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:

 1 struct HTTPResponse {
 2     let value: Tweet?
 3     let response: URLResponse
 4 }
 5 
 6 let tweetsPublisher = publisher.retry(3)
 7                                .tryMap { result -> HTTPResponse in
 8                                   let tweet = try decoder.decode(Tweet.self, from: result.data)                                  
 9                                   return HTTPResponse(value: tweet, response: result.response)
10                                }
11                                .receive(on: DispatchQueue.main)
12                                .eraseToAnyPublisher()


By encapsulating the response in a HTTPResponse you 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:

 1 struct HTTPResponse {
 2     let value: T
 3     let response: URLResponse
 4 }
 5 
 6 struct HTTPClient {
 7     let session: URLSession
 8 
 9     func perform(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher, Error> {
10         return session.dataTaskPublisher(for: request)
11             .retry(3)
12             .tryMap { result -> HTTPResponse in
13                 let tweet = try decoder.decode(T, from: result.data)                                  
14                 return HTTPResponse(value: tweet, response: result.response)
15             }
16             .receive(on: DispatchQueue.main)
17             .eraseToAnyPublisher()
18     }
19 }


This way, you can handle any type of expected data from our server.

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

 1 struct TwitterAPI {
 2   let client: HTTPClient
 3   
 4   func getTweetsFor(screenName: String) -> AnyPublisher {
 5     let request = TimelineEndpoint.getFor(screenName: screenName).urlRequest
 6     return client.perform(request)
 7                   .map(\.value)
 8                   .eraseToAnyPublisher()
 9   }
10 }


So this is it. Stay tuned for more information on the subject.

More articles from Ciprian Redinciuc can be found on Userdesk or on our blog.