When talking about ReactiveSwift, and more specifically about SignalProducer
, the companion example usually is a network call. Some of those examples start with a "non-reactive" version of the problem and wrap it using a SignalProducer
. In this post I will do the same, but extend it by explaining why a Signal
is the wrong abstraction for this problem.
Note: In this post I will assume a basic understanding of RAS.
Let's start with the non-reactive example:
func fetch(with request: URLRequest,
handler: @escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionDataTask {
return session.dataTask(with: request, completionHandler: handler)
}
This is a fairly straighforward implementation. We expose one single method that a consumer can use by passing a URLRequest
and the expected handler
to the URLSession
.
The whole network entity could then be in the shape of:
final class Network {
private let session: URLSession
init(session: URLSession = URLSession.shared) {
self.session = session
}
func fetch(with request: URLRequest,
handler: @escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionDataTask {
return session.dataTask(with: request, completionHandler: handler)
}
}
Now that we have the basics, let's look at how it could look like with ReactiveSwift.
A question I make, even before bothering with an implementation, is what types am I working with. This helps me personally to understand the flow of information and create a mental model of how the different components of the system fit together.
For our example, we know a couple of things:
- It will take a
URLRequest
. - And that we need a
SignalProducer
, because it's how we encapsulate asynchronous work in ReactiveSwift. Also that we no longer need thehandler
, because that logic will now be inside theSignalProducer
.
We can make an educated guess that the API will look like this:
func fetch(with request: URLRequest)
-> SignalProducer<(Data?, URLResponse), Error> {}
It's also important to notice that, because there is a distinct success/failure path, we no longer need all those optionals. Also, because we want to be precise about the nature of our Error
, we will introduce our own:
enum NetworkError: Error {
case error(Error)
case unknown(String)
}
For simplicity sake this will suffice, although we could further define what unknown
entails:
func fetch(with request: URLRequest)
-> SignalProducer<(Data?, URLResponse), NetworkError> {}
Finally, wrapping our API:
// MARK: Reactive Extensions
extension Network {
func fetch(with request: URLRequest) -> SignalProducer<(Data?, URLResponse), NetworkError> {
return SignalProducer { observer, lifetime in
let task = fetch(url: url, handler: { (data, response, error) in
// Check if we have any error, if we do,
// just terminate the producer by sending an error
guard error == nil else {
observer.send(error: .error(error!))
return
}
// Check if we have response. In theory if we
// have no error, then we should have a response.
// Terminate the producer by sending an error
// otherwise
guard let response = response else {
observer.send(error: .unknown("Response missing"))
return
}
// We got everything we need, so send it as a next
// and terminate the stream via `sendCompleted`.
observer.send(value: (data, response))
observer.sendCompleted()
})
// Start the task. This will only be called
// when we start the producer.
task.resume()
// Cancel the task if the observer is gone before
// a response comes. An example would be the user
// popping a UIViewController before we got a
// response from the server.
lifetime.observeEnded {
task.cancel()
}
}
}
}
From a consumer point of view, this would look like this:
let producer = network.fetch(with: request)
producer.startWithResult { result in
switch result {
case .success(let value): break
case .failure(let error): break
}
}
Why a SignalProducer
instead of a Signal
?
Although I could go with the semantical route (cold vs hot), I will go with the "let's try it and see if it works" route. 😅
Assuming we want to make this work with a Signal
, our new API becomes:
func fetch(with request: URLRequest)
-> Signal<(Data?, URLResponse), NetworkError> {}
The canonical way of initializing a Signal
is via the pipe
operator, but to be aligned with the SignalProducer
approach, we will use this instead:
let signal = Signal<(Data?, URLResponse), NetworkError> { observer, lifetime in
/// ...
}
The implementation will be identical to the one where we use the SignalProducer
, since we have access to both the observer
and the lifetime
parameters. At the call site it would look slighly different:
let signal = network.fetch(with: request)
signal.observeResult { result in
switch result {
case .success(let value): break
case .failure(let error): break
}
}
The difference is subtle, but we already get an hint why a Signal
is not suitable for this: startWithResult
versus observeResult
:
A SignalProducer
won't start any work until we explicity tell it to do so. A Signal
will immediatly start as soon as it is created (when we call fetch
) and we can only peak into what's going on via observeResult
[1].
This means that with the Signal
implementation, we might be "lucky" and observe the result
. On the other hand if we have a fast internet connection, we might not, because the value has already being sent and we weren't observing it. With a SignalProducer
is guaranteed that we will see the result
, since we are responsible for starting the work (in this case via startWithResult
).
This also ties quite nicely to the idea of cold vs hot. A SignalProducer
has cold semantics, because it's considered "cold", until we explicitly tell it to start (and becomes "hot"). While a Signal
is "hot" because it already started, even before we consume it (via observeResult
).[2]
Hopefully I was able to shed some light into this topic. In a future post, we will see when and why a Signal
makes sense and how we can tie both concepts together.
As usual, any questions let me know via twitter.
It's important to mention that "starting" and "sending values" are different things. As we saw in the network request example, the network has already been fired (so it has started), but it doesn't necessarily mean that by the time we start observing it has sent values. ↩︎
Or any other
observe
family methods. ↩︎