Swift networking using Combine

Introducing Combine
Introduced in 2019, Combine provides a declarative Swift API for processing values over time and it offers a huge benefit when it comes to asynchronous programming. Combine makes it easy to chain together operations such as transforming values, filtering out values, and even combining values from multiple publishers thanks to built-in operators such as map, filter, reduce, and combine.
Combine and SwiftUI can be used together to create beautiful, modern user interfaces. With Combine, you can easily create bindings between your state and your UI elements, making it easier to keep your data up-to-date with minimal effort. Combine also makes it easy to perform asynchronous tasks, such as fetching data from an API or downloading files, without blocking the main thread.
Publishers, Operators and Subscribers
There are three key concepts in Combine that we need to understand:
![Filtering events emitted by a publisher]](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Freactive-filter.20a2e7c1.png&w=1080&q=75)
- Publishers
A publisher emits elements to one or more Subscribers - a UITextField changing its contents as the user types. - Operators
Operators are higher order functions that help us manipulate the elements emitted by a Publisher - validating the text the user input. - Subscribers
Subscribers are instances that are interested in elements emitted by a Publisher - presenting an error if the input is invalid.
If you want to find out more about Combine I recommend cheking the docs.
URLSessionDataPublisher
The addition of Combine support to URLSession makes it easier for iOS developers to create, manage, and monitor network requests. Specifically, URLSessionDataPublisher allows developers to publish request data and manage data flow with Combine operators like map, filter, and reduce.
This allows developers to create asynchronous requests with a type-safe, declarative syntax. In addition, Combine support makes it easier to integrate network requests with other flows, such as user input or other events.
Creating a URLSessionDataPublisher is pretty 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 mentioned earlier, the sink method allows us to create a subscription to a publisher that will then receive values from it.
let subscription = 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)
}
)
Once the subscription is created, the publisher will start emitting values, and the closure we provided to the sink method will be called for each one.
When we’re done receiving values, we can cancel the subscription to stop the publisher from emitting any more values. This is done using the cancel method on the subscription:
subscription.cancel()
Mapping responses with Codable
Combine offers a variety of useful operators that can be used to transform a JSON response into a model representation.
For instance, operators such as map
or tryMap
and decode
can help you 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
operator can be used to transform the data that is returned by a DataTaskPublisher. It takes a closure with two parameters - data, which is a Data instance, and a URLResponse instance called response. This closure can be used to modify the data before it is returned to the subscriber.
The tryMap
operator works similarly, but it allows the closure to throw an error, which can be handled by the subscriber.
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.
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 also ensure that the response is structured in a consistent and predictable way. This makes it easier for developers to understand and work with the response, and makes it easier to debug any potential issues.
Thanks to the retry
operator we can set a maximum number of attempts we want to retry a request, as well as a delay between attempts.
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 we couldn't make it a generic one that works with any type that we want.
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 data in a type-safe manner. If you are interested 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()
}
}