The article discusses how to convert an iOS network layer to work with Combine or async/await technologies.
Abstract
The article begins by explaining the three ways to perform network operations in Swift: using the old callbacks model, Combine, or async/await. The author suggests that while consistency is a benefit of using one technology throughout an app, it can limit flexibility. To demonstrate how to convert a network layer from one technology to another, the author uses a network layer as an example. The article covers using callbacks, Combine, and async/await, and how to consume them with each technology. The author concludes by discussing the various technologies and how to translate interfaces from one world to another.
Bullet points
Swift introduced the new async and await keywords, providing three ways to perform network operations: using the old callbacks model, Combine, or async/await.
Consistency is a benefit of using one technology throughout an app, but it can limit flexibility.
Using callbacks involves accessing a URLSession object and using the dataTask(with:completionHandler:) method to obtain a URLSessionDataTask object.
To use Combine, we can use the dataTaskPublisher method to issue a request and subscribe to the publisher to receive the response.
To use async/await, we can use the data method of URLSession to issue a request and handle the response with async/await syntax.
The article demonstrates how to convert a network layer from one technology to another, using callbacks, Combine, and async/await, and how to consume them with each technology.
The author concludes by discussing the various technologies and how to translate interfaces from one world to another.
How to Convert Your iOS Network Layer to Work With Combine or Async/await
Adapting your network layer to different interfaces
This year, Swift introduced the new async and await keyword. With them, we now have three ways to perform network operations:
By using the old callbacks model
By using Combine
By using async-await
In some cases, we make a decision early on one technology and we try to use it in all the modules of our app: if we start with Combine, for example, we may want to use Combine everywhere.
This approach has the clear upside of consistency: we don’t have to think about how to implement something and people joining the project won’t be surprised with multiple technologies when exploring the codebase.
The cost of consistency is flexibility. We would like to use the best tool for each situation. We could have modules that require us to use Combine for streams of events and others that could work with a callback for one-shot operations.
In today’s article, we explore how to convert a network layer from one technology to another. The network layer is just an example: whenever you need to transform an API from a technology to another, we can tap into these techniques.
Using Callbacks
The first technology we have for asynchronous operation is a callback-based one. We access an URLSession object and we use the dataTask(with:completionHandler:) method to obtain a URLSessionDataTask object. The task can be started by invoking the .resume() method.
A typical implementation looks like this:
In this code, we have a generic CallbackNetworkService that we can use to perform GET requests against a backend.
The service can be configured by passing an URLSession object.
The get<T: Decodable>(url:callback:) method creates the URLRequest object, it invokes the dataTask(with:callback:) method and then resume() on the task.
Most of the response handling code is devoted to managing errors.
We check whether the network returned an error or not.
We check whether the backend returned an error or not.
We verify that we have some data to decode.
We try to decode the data. If the decoding succeeds, we invoke the callback with the object.
Otherwise, we notify the caller that a decoding error or an unknown error occurred.
Consume With Callback
Once we have the network service, we want to use it. Let’s imagine that we want to retrieve the data of a user in a Profile module.
The Profile module requires a service with the following interface:
The composition root, where we write the code to connect all the modules of our app, can create a simple Adapter to connect the two interfaces. When the two interfaces share the same technology, the code is quite simple.
The adapter gets a CallbackNetworkService as init parameter and it conforms to the CallbackUserService. It implements the getUser(id:callback:) by creating the URL and invoking the networkService.get method.
Given that the two callbacks share the same signature, we can pass the UserService’s callback as callback of the NetworkService. Usually, we may need to transform the Result obtained by the NetworkService and manually invoke the UserService callback.
Consume With Combine
Imagine now that the Profile module is using Combine. In that case, the UserService interface is different and it looks like this.
The method simply accepts a user identifier and returns a publisher.
The CallbackToCombineAdapter has to create a publisher before performing the network request. The code looks like this:
The adapter’s setup is identical for all the adapters we are going to see in the article. We define a type that conforms to a service protocol, we pass a networkService and we implement the protocol conformance by creating the URL. We are not going to discuss this part in the following adapters.
When implementing the protocol, we create a publisher that can perform the networking. We create a Deferred publisher: it takes a closure that returns a publisher. The closure is executed when a subscription is received. We need this because there are some publishers that publishes as soon as they are returned, like the Just or the Fail publishers, but for this use case, we need to wait until something subscribes to the publisher before issuing the network request.
Then, we create a Future: a publisher that eventually will publish a value. The body of the publisher actually performs the network request by invoking the networkService. The Future publisher gives us a promise which shares the same signature of the NetworkService callback, so we can use it as a callback for the networkService itself.
Consume with Async Await
In the case the Profile module uses async await, we need to convert the network service to the following interface.
The code of the adapter that can convert the networkService to the AsyncAwaitUserService is the following:
To implement the adapter, we need to wrap the networkService.get method in the withCheckedThrowingContinuation closure. This API lets us transform a callback-based async API into an async-await one. The continuation is an object that lets us resume the execution with a value or that lets us interrupt the execution by throwing an error.
There are other versions of the withXXXContinuation API we can use, depending on the use case:
withCheckedThrowingContinuation: it can be used when the body can throw an error. The checked part of the name tells us that the function will perform some checks to make sure that every branch invokes the continuation once and only once.
withCheckedContinuation: can be used when the body can’t throw an error. The checked part of the name tells us that the function will perform some checks to make sure that every branch invokes the continuation once and only once.
withUnsafeThrowingContinuation: can be used when the body can throw an error. The unsafe part of the name means that the API does nothing to make sure that the continuation is invoked once for each execution branch.
withUnsafeContinuation: can be used when the body can’t throw an error. The unsafe part of the name means that the API does nothing to make sure that the continuation is invoked once for each execution branch.
Using Combine
Let’s suppose now that the network layer has been implemented using Combine. In this case, the URLSession method used is the dataTaskPublisher and it does not require to invoke resume().
The code implements the same functionality as the previous network service. We create a dataTaskPublisher, passing a URLRequest. We try to map the result to Data, to decode it. While doing that, we also verify that the backend didn’t raise any error. Finally, we erase it to AnyPublisher.
Let’s see how we can consume it.
Consume With Callback
The first interface is the CallbackUserService. The adapter looks like this.
The main difference from the previous adapters is that we need to handle the Combine subscription. To do so, the adapter needs to be a class and it needs to owns a Set<AnyCancellable> to store the subscriptions.
The getUser method is implemented by subscribing to the publisher and by invoking the callback with a .success value, when a published value is received. If we receive a completion with a failure, we invoke the callback with a .failure value.
Note: This is a naive implementation to keep the example short and easy to understand. Another more general implementation could collect the received value and invoke the callback upon successful completion.
Consume with Combine
When also the client is using Combine, the adapter prepares the publisher and forwards it.
In this case, the adapter does not have to handle the subscription. The CombineUserService implementation prepares the URL and returns the publisher to the caller.
Consume With Async-Await
Finally, let’s see how to convert the Combine service to the async-await one.
Also for this conversion, we need to use the withCheckedThrowingContinuation API. The code below shows how to use it:
This adapter has to handle the subscription, so it is a class with its own set of Cancellables. The continuation API is used to wrap the subscription to the publisher.
Once we have the continuation object, we can mimic the callback implementation, using the continuation.resume(returning:) API when the publisher sends a value, and the continuation.resume(throwing:) API when we receive a completion with an error.
Using AsyncAwait
The last technology of the pack is the new async-await API. It can simplify a lot our code, and, starting from Xcode 13.2, it has been backported to work also with iOS 13.
The AsyncAwaitNetworkService looks like this
We use the data method of the URLSession to issue the request. If the request fails, we throw an error. Otherwise, we get our data and the URLResponse object. Then, we examine the status code, to understand whether the backend returned an error. If everything is fine, we try to decode the object.
Let’s consume it with the various Profile’s services.
Consume with Callback
The first adapter translates the async-await paradigm to the callback model. To execute some async-await function in a non-async function, we need to use the Task API.
Let’s focus on the Task API. We use it to wrap the invocation of the get method of the network service which is async. When the method completes, we may have a valid User and we invoke the callback with a success value.
In case of an error, we catch it and we invoke the callback with the error itself.
Consume With Combine
The AsyncAwaitToCombineAdapter is very similar to the one with callbacks. We still have to produce a publisher and we have to use the Task API to convert the async-await code to something that can be consumed by Combine.
The code looks like this:
The code shares the same structure as the CallbackToCombineAdapter. We create the Deferred and the FuturePublishers to enter into the Combine world. Once we have the promise from the Future publisher, we use the Task API to execute the async work. The body of the Task is very similar to the one of the AsyncAwaitToCallbackAdapter: when the async function completes, we use the promise to publish the result.
Consume With AsyncAwait
The last adapter is straightforward. We have the same technology and the code is very intuitive.
The only thing to do is to invoke the networkService.get method, using the try await keywords.
Conclusion
In today’s article, we explored the various technologies we can use to perform network requests. We discussed the basic callback model, an implementation with Combine, and implementation using async-await.
Then, we discussed how to convert them from one technology to another. By converting the network service, we explored various Apple APIs that helped us in the task like the Deferred and the Future publishers, and the Task and Continuation API.
It’s important to know how to translate interfaces from one world to another. We have seen these techniques in the context of URLSession and network requests, but they can be applied to every asynchronous API we may encounter.