An Introduction to Combine - Part 4
Welcome back to the 4th and last part of this introduction to Combine. In this article our goal is to apply Combine to our Networking/Service layer. If you lost the previous articles, here are part 1, part 2 and part 3.
Today, the first construct that I want to show you is Future
.
Future
Future
is a special kind of Publisher
that we can use to manually send a value asychronously, just like we did with Subject
s, but we're only allowed to either send a single value and complete or fail (if you come from RxSwift, it's the same as Single
). Future
is perfect to adapt closure-based APIs to Combine, and I'll create an extension of our BalanceService
protocol to show you exactly what I mean.
extension BalanceService {
func refreshBalance() -> AnyPublisher<BalanceResponse, Error> {
Future { promise in
self.refreshBalance { result in
do {
let response = try result.get()
promise(.success(response))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
}
Our new version of refreshBalance()
calls the old one inside a closure that Future
receives on its constructor, where we can execute our asynchronous tasks. This closure also provides another closure as input, the promise
parameter, and we should use that to call back when our asynchronous tasks are finished with a Result
object indicating a success or a failure. In fact, the shape of the promise
closure is exactly the same as the completion
parameter in the original refreshBalance
function, so we don't really need to unwrap the result, we can just forward promise
like this:
extension BalanceService {
func refreshBalance() -> AnyPublisher<BalanceResponse, Error> {
Future { promise in
self.refreshBalance(completion: promise)
}
.eraseToAnyPublisher()
}
}
We could also return Future<BalanceResponse, Error>
instead of an AnyPublisher
, but that would leak some implementation details to the caller so I usually prefer erasing to AnyPublisher
.
Now we need to adjust BalanceViewModel
to call the new version of refreshBalance()
, and we'll do it by calling sink
on the "erased" Future
:
final class BalanceViewModel {
(...)
private func refreshBalance() {
state.didFail = false
state.isRefreshing = true
service.refreshBalance()
.sink(
receiveCompletion: { [weak self] completion in
self?.state.isRefreshing = false
if case .failure = completion {
self?.state.didFail = true
}
},
receiveValue: { [weak self] value in
self?.state.lastResponse = value
}
)
.store(in: &cancellables)
}
}
This time we can't ignore the receiveCompletion
closure because the Publisher's failure type is Error
(instead of Never
), so it can really fail.
I won't lie: this sink
with two closures is pretty ugly and makes me remember the APIs from the old ObjC days, and even some from early versions of Swift, before we had the Result
type (which I first saw in Alamofire, long before we had an official version in the Foundation framework). On the other hand, this makes it clear to the caller that these two callbacks may not be called together, example: if we prepend-v9sb) a cached value synchronously then fire the request we'll have receiveValue
called twice before receiveCompletion
.
Let's run our tests to see if everything works. Tests pass. Here is the full commit.
One thing that's missing here is the following: if the original refreshBalance(completion:)
function would return some kind of cancellable token, we should cancel the original request manually as the subscriptions to our Future
are cancelled. The way for us to receive that information from Future
is appending a handleEvents(receiveCancel: { ... })) call before eraseToAnyPublisher()
. However, the whole thing would be a bit more complicated than that and would go beyond the scope of this article, so I'll leave it as an exercise to the reader.
URLSession extensions
Now we're gonna throw our BalanceService
extension away and use Combine directly in the definition of the protocol to see what happens. This is our new BalanceService
protocol.
protocol BalanceService {
func refreshBalance() -> AnyPublisher<BalanceResponse, Error>
}
I'll also get rid of the FakeBalanceService
and implement a live version, which makes a real request to fetch this JSON here and parses the reponse data to a BalanceResponse
. For that I'll use the Combine extensions that come with URLSession
, but you can choose your preferred one. Good networking libraries like Alamofire will provide built-in support for Combine.
We'll start by making BalanceResponse
a Decodable
type and updating App Transport Security Settings
in my info plist to allow arbitrary loads. Then we implement the live service like this:
struct LiveBalanceService: BalanceService {
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
decoder.dateDecodingStrategy = .formatted(dateFormatter)
return decoder
}()
private let url = URL(
string: "https://api.jsonbin.io/b/60b76b002d9ed65a6a7d6980"
)!
func refreshBalance() -> AnyPublisher<BalanceResponse, Error> {
URLSession.shared
.dataTaskPublisher(for: url) // 1
.tryMap { output -> Data in // 2
guard let httpResponse = output.response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return output.data
}
.decode(type: BalanceResponse.self, decoder: decoder) // 3
.receive(on: DispatchQueue.main) // 4
.eraseToAnyPublisher() // 5
}
}
Starting from the refreshBalance()
function:
- We use the
dataTaskPublisher(for url: URL)
extension fromURLSession
to make a simpleGET
request; if we needed to do a custom request we would usedataTaskPublisher(for request: URLRequest)
instead; - When the request returns we check its output (of type
URLSession.DataTaskPublisher.Output
) to validate the response status code, and if it's OK we propagate theData
; - We decode the response
Data
to aBalanceResponse
using a customJSONDecoder
that knows how to convert"2021-06-02 11:01:48 +0000"
into aDate
object; - As
URLSession.shared
works on its own queue, we usereceive(on:)
to dispatch theBalanceResponse
to the main queue before it reaches our ViewModel, which will generate the UI updates and must do that on the main queue; - Last, we erase the resulting publisher to
AnyPublisher
, otherwise we would have a huge return type (as we saw in the previous articles), and that would have to leak to the protocol;
About step 4, we could have done the async dispatch inside our ViewModel too but then we would have asynchronous code in our tests. Furthermore threading is a cross-cutting concern and we can apply other design patterns to solve that in a way that our business logic doesn't have to know about threads. I'll make sure I come back to this in another article.
Now we need to replace all the places where we were using FakeBalanceService
to use LiveBalanceService
. This will include the BalanceViewController
previews which is not ideal, but we'll return to this soon. After some tweaks we get the project compiling again and we can see our live service in action.
The test target, however, still need adjustments. I usually fix the tests before comitting the changes but this time I'll commit the current state as it is so that you can take a look at the diff.
Synchronous Publishers
It's time to fix our BalanceServiceStub
so that it conforms to BalanceService
again. Let's recap how it looks at the moment:
class BalanceServiceStub: BalanceService {
private(set) var refreshCount = 0
var result: Result<BalanceResponse, Error>?
func refreshBalance(
completion: @escaping (Result<BalanceResponse, Error>) -> Void
) {
refreshCount += 1
if let result = result {
completion(result)
}
}
}
When we need to test scenarios where the service returns some response, we set that result
variable to .success(BalanceResponse(...))
so that when refreshBalance
is called we call completion
synchronously with the stubbed result. We can also set result
to .failure(...)
when we want to test failure scenarios, or set it to nil/.none
when we want to check the system state when it's waiting for the response.
Well, now that we know how Future
works we could be lazy and add that same function to BalanceServiceStub
to fix everything:
func refreshBalance() -> AnyPublisher<BalanceResponse, Error> {
Future { promise in
self.refreshBalance(completion: promise)
}
.eraseToAnyPublisher()
}
As completion
is always called synchronously this works, but I want to take another path and show you other useful Publisher
s. This is how we're gonna implement refreshBalance
:
func refreshBalance() -> AnyPublisher<BalanceResponse, Error> {
refreshCount += 1
switch result {
case .failure(let error):
return Fail(outputType: BalanceResponse.self, failure: error)
.eraseToAnyPublisher()
case .success(let response):
return Just(response)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
case .none:
return Empty(completeImmediately: false)
.eraseToAnyPublisher()
}
}
Let's analyze them from the perspective of the subscriber:
Just
sends its value and completes successfully, everything happening synchronously when yousink
to it; we also need to set an appropriate failure type here, becauseJust
has aNever
failure type by default;Fail
will also complete immediately with afailure
completion containing theerror
and won't send any value;Empty
will never send any value too but will complete immediately with.success
unless we create it withcompleteImmediately: false
, in this case it'll never complete as well.
With our BalanceServiceStub
fixed we can run the tests again. They pass. Commit and push. We're done!
Conclusion
With this article we finished this practical introduction to Combine. I hope it helped you understand the fundamentals of the framework and how to think in a reactive way.
I'll certainly write more Combine it in the future to explore different ways to compose publishers, advanced operators, back pressure, etc., but I think what we saw in the series covers a lot of what we do in a daily basis.
If this helped you, it's your turn to help me by sharing these articles with your dev network. You can also send me a message on LinkedIn or Twitter, I'd love to hear your feedback.
Now let's see what WWDC 21 brings to us... I'm pretty sure async/await in Swift 5.5 will become the default way to model services and we probably won't use Combine in this context. To be continued.
See you next time!