Exploring Networking and Combine
Networking is a great place to start if you haven’t worked with Combine. Combine is a framework that declaratively handles a stream of values over time while supporting asynchronous operations.
Based on that description, it looks like Combine was made for networking operations!
In this chapter, we are not going to discuss what Combine is – for that, we’ve got Chapter 11. However, we are going to discuss it now because Combine is a great way to solve many networking operations problems.
Since Combine is built upon publishers and operators, it is simple to create new publishers that stream data from the network.
Let’s try to request the list of contacts from previous examples using a Combine stream. We’ll start with creating a publisher that performs data fetching from the network and publish a list of contacts:
class ContactRequest { func fetchData() -> AnyPublisher<[Contact], Error> { let url = URL(string: "https://api.example.com/contacts")! return URLSession.shared.dataTaskPublisher(for: url) .map { $0.data } .decode(type: [Contact].self, decoder: JSONDecoder()) .eraseToAnyPublisher() } }
The publisher utilizes URLSession’s dataTaskPublisher
method to execute the network request and publish the retrieved data. We then extract the data using the map operation and decode it into a list of Contact
items. If something goes wrong, the publisher will report an Error. We wrap this function in a class named ContactRequest
to maintain separation.
Now, let’s create a small DataStore
class so we can store the results and publish them:
class DataStore { @Published var contacts: [Contact] = [] }
The @Published
property wrapper creates a publisher for contacts so that we can observe the changes easily.
Now, we can use the fetchData()
function to read the results and store them:
class ContactsSync { let contactRequest = ContactRequest() let dataStore = DataStore() func syncContacts() { contactRequest.fetchData() .sink(receiveCompletion: { completion in switch completion { case .finished: print("Data fetch completed successfully") case .failure(let error): print("Error fetching data: \(error)") } }, receiveValue: { [weak self] contacts in self?.dataStore.contacts = contacts }) .store(in: &cancellables) } private var cancellables = Set<AnyCancellable>() } let contactsSync = ContactsSync() contactsSync.syncContacts()
The ContactsSync
job is to fetch contacts using the ContactRequest
class and to store them in the data store using the DataStore
class.
The Combine example has several advantages:
- Clear and consistent interface: The publisher interface is consistent and known. It is always built from data/void and an optional error. New developers don’t need to learn and understand how to read/use it.
- Built-in error handling: Not only do we have a consistent interface that also contains errors, but also, when one of the stages encounters an error, it interrupts the flow and channels it downstream. We have already seen that error handling is a critical topic in networking in many cases.
- Asynchronous operations support: We often think that a network operation contains one asynchronous operation: the request itself. However, many steps in the stream can be asynchronous – including preparing the request by reading local data, processing the response, and storing the data at the end of the stream. Combine streams are perfect for performing all those steps asynchronously.
- Modularity: The capability of building a modular code is reserved not only for the Combine framework, but the custom publishers and the different operators make Combine streams a joyful framework to implement when dealing with networking. Remember that we said that networking is like a production line (under the Understanding mobile networking section)? So, Combine makes it easier to insert more steps into the stream; some of them are even built into the framework.
Adding reactive methods to our code doesn’t mean we need to discard all the design patterns and principles we discussed when we covered networking—it’s just another way to implement them.
For example, let’s try to implement the delta updates design pattern using the Combine framework:
URLSession.shared.dataTaskPublisher(for: request) .tryMap { output in guard let response = output.response as? HTTPURLResponse, response.statusCode == 200 else { throw URLError(.badServerResponse) } return output.data } .decode(type: ContactsDeltaUpdateResponse.self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): print("Error during sync: \(error.localizedDescription)") } }, receiveValue: { [weak self] response in self?.processDeltaUpdates(response: response) }) .store(in: &cancellables)
Looking at the code example, we can see that it looks pretty much like the previous Combine code—that’s part of the idea of consistent interface and modular code. We perform the request, check the response code, decode it, change it to the main thread, and process the response data.