Original article at http://www.gfrigerio.com/networking-example-with-combine/
This is the second post about Combine, after my quick introduction you can find here
I’m using the same GitHub project but this time I’m focusing on networking, with a similar example to the one I used for my series of posts about Future&Promise, RxSwift and AlamoFire. I’ll build the same table view controller with a list of users, calling public accessible remote APIs you can find at this link
The sample project
As I mentioned we’ll make calls to some remote APIs in order to retrieve information about a list of users. I’ve already described the APIs on the post about Future and Promises you can find at this link so here’s going to find only a quick recap. I make 3 calls: one for the pictures, the second for the albums and the third one to retrieve the users. Then I merge the pictures with the albums, and the albums with the users.
Since I need the 3 calls to succeed in order to proceed I’m going to use Zip to merge the publishers together and be notified when the 3 calls are completed. You’ll find some error handling as well, as this time we’ll deal with publishers that can fail, as we’re making remote calls and deal with data we cannot control.
Remote calls
As the project is about networking I think it is better to start with the calls we need to make. The client is very simple, just one function call, you can see the code here
enum RESTClientError : Error {
case error(error:String)
}
Let’s start with error handling. As our calls can fail, it is better to deal with it. As you’ll see I eventually get rid of the error providing a default data, but I think this logic has to be implemented elsewhere, not in the REST client. When an error occurs, I’ll create a new error of type RESTClientError and pass it to the subscriber.
class func getData(atURL url:URL) -> AnyPublisher {
let session = URLSession.shared
return AnyPublisher { subscriber in
let task = session.dataTask(with: url) { data, response, error in
if let err = error {
subscriber.receive(completion: .failure(RESTClientError.error(error: err.localizedDescription)))
}
else {
if let data = data {
_ = subscriber.receive(data)
subscriber.receive(completion: .finished)
}
else {
let unknownError = RESTClientError.error(error: "Unknown error")
subscriber.receive(completion: .failure(unknownError))
}
}
}
task.resume()
}
}
as you can see, getData returns a publisher that can either publish some Data, or an error of type RESTClientError.
To make the call I use the dataTask method of URLSession and as you know it can return some data, or an error.
Let’s see what happens in case of error first. The subscriber will only receive a completion, and that’s an enum with two values: finished and failure. We created our custom Error enum, so we can use that for the failure. I use the localizedDescription of the error, if I have one, or just a string if I don’t know the error type.
What happens when the call returns something? The subscriber get called twice, one with the data, and one with the completion as we already sent all the data that we have. Notice I didn’t use the value returned from receive(data), that would be a Demand, indicating how many elements the subscriber expects to receive. I’m sending all the data I have once, so I don’t care about the Demand. For your information, the value can be unlimited, or can be an Int.
You can achieve the same result using a Future. Quoting from Apple’s implementation: a Future is a publisher that eventually produces one value and then finishes or fails. Sounds like a good candidate for our REST client, each call will either fail or give us some data we can use.
class func getData(atURL url:URL) -> Future {
let session = URLSession.shared
return Future { promise in
let task = session.dataTask(with: url) { data, response, error in
if let err = error {
promise(.failure(RESTClientError.error(err.localizedDescription)))
}
else {
if let data = data {
promise(.success(data))
}
else {
let unknownError = RESTClientError.error("Unknown error")
promise(.failure(unknownError))
}
}
}
task.resume()
}
}
We still have to deal with an enum, but this time we have .success with an argument of type Data and a .failure with an error. Seems cleaner to me, no need to call the subscriber twice in case we have data.
The implementation of the DataSource class doesn’t change, so we can either use AnyPublisher or Future for our REST client. One of the differences is the caller cannot control how many values it wants to receive, as a Future will either produce one value or an error it doesn’t make sense to have that functionality.
Data Source
Now let’s get to the DataSource class responsible for getting the JSONs from the REST client and convert them in the structs we need, pictures, albums and users. The class is more complicated than the one dealing with URLSessions, so I’ll describe it in more steps. Let’s start with a generic call to the rest client
private func getEntity(_ entity:Entity) -> AnyPublisher {
guard let url = getUrl(forEntity: entity) else {
return Publishers.Fail(error:DataSource.makeError(withString: "cannot get url")).eraseToAnyPublisher()
}
return RESTClient.getData(atURL: url)
.catch { _ in
Publishers.Fail(error:DataSource.makeError(withString: "error converting data")).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
First we try to get the URL of one of the entities. If we fail at that (actually it shouldn’t happen…) we need to tell our subscriber we failed. This time I took a slightly different approach, I don’t return always the same publisher and send different receive events to the subscriber, but I return a publisher than immediately fails in case we don’t have the URL, otherwise I make the call to the rest client and return my publisher based on that.
private func getAlbums() -> AnyPublisher<[Album], Never> {
return getEntity(.Album)
.decode(type: [Album].self, decoder: JSONDecoder())
.catch { error in
Just<[Album]>([])
}
.eraseToAnyPublisher()
}
This is how we get the Albums. We try to decode the data received from the publisher we just saw, and if something goes wrong (for example when we return the default data) we can publish an empty array. We can use Just for that purpose. Just is a publisher that emits a single value and finishes, and, according to Apple’s own documentation, is also useful when replacing a value with catch, exactly what is happening in the example.
So at the end of the call either an array of albums, or an empty array, will be sent to the subscriber.
Let’s now see how we can merge all the values together. In my previous post about Combine I used CombineLatest, really useful when you deal with values that can change over time like the UITextField we were monitoring. This time we need to make 3 calls, and be notified when all of them returned a value. For that, we can use Zip. The difference with CombineLatests is that we get called only once for the N publishers we subscribe to, while CombineLatests continues to deliver values.
func getUsersWithMergedData() -> AnyPublisher<[User], Never> {
return Publishers.Zip3(getPictures(), getAlbums(), getUsers())
.map {
let mergedAlbums = DataSource.mergeAlbums($1, withPictures: $0)
return DataSource.mergeUsers($2, withAlbums: mergedAlbums)
}
.eraseToAnyPublisher()
}
Notice I use $0, $1 and $2 to refer to the result of getPictures, getAlbums and getUsers. The new publisher I return here will eventually emit the result of mergeUsers, so an array of users with their albums and pictures.
Finally let’s take a look at the to getUsersWithMergedData
@IBAction func showUsersTap(_ sender: Any) {
_ = dataSource.getUsersWithMergedData().sink { users in
DispatchQueue.main.async {
let usersVC = UsersTableViewController()
usersVC.setUsers(users)
self.navigationController?.pushViewController(usersVC, animated: true)
}
}
// get users and show UsersTableViewController
}
We use .sink to have a closure with the value we expect from a publisher.
Users table view
Know we have the array of users and we can finally show them in a UITableView. Wouldn’t it be great if we could search through the list? It isn’t really necessary here, but I’ll use Combine once more to apply a filter to our table, based on a UISearchViewController. You can see the code here
I already talked about @Published in my previous post about Combine. I’m using the same property wrapper for applying a filter, so every time the value of the variable changes I can apply it to the list of users and filter them.
@Published var filter = ""
func setUsers(_ users:[User]) {
self.users = users
_ = $filter.sink { value in
self.applyFilter(value)
}
filter = ""
}
func updateSearchResults(for searchController: UISearchController) {
if let searchText = searchController.searchBar.text {
filter = searchText
}
else {
filter = ""
}
}
So every time the text filed changes I update the variable filter and the same value is used to filter elements of the array.
Again, Combine isn’t really necessary here, I could have easily called applyFilter on updateSearchResults without the need for a publisher. I used Combine to demonstrate it can be handy to use a publisher for those scenario as well.
That's all, happy coding :)
Top comments (0)