Swift Combine Cancel Publishers Without AnyCancellable - ios

I have my networking library based on Combine. Anywhere in my app I can make a request and the networking library returns a publisher, it doesn't have access to the AnyCancellable that is created that actually triggers the pipeline. What I need is the ability to cancel all network requests when the use logs out. Is there a way to cancel Combine pipelines from the publisher not the AnyCancellable.
Here is an example:
var subscribers = [AnyCancellable]()
let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
let request: AnyPublisher<UIImage, URLError> = URLSession.shared.dataTaskPublisher(for: url)
.compactMap { UIImage(data:$0.data) }
.share()
.eraseToAnyPublisher()
request
.sink(receiveCompletion: { _ in
print("subscription2 completed")
}, receiveValue: { image in
print("subscription1 value: \(image.scale)")
})
.store(in: &subscribers)
// request.cancel()
I would like to call something like request.cancel() on the publisher so that the receiveValue is never triggered.

What I need is the ability to cancel all network requests when the use logs out.
I suggest you setup a publisher that emits when the user logs out. Then in your API system, you can use prefix(untilOutputFrom: logoutPublisher).
That way, all your network requests will cancel when the logoutPublisher emits.

The sink method returns a single AnyCancellable that you can store in
a dedicated property for the request instead of storing the cancellable in the array.
When you need to cancel the subscription, just deinitialize the cancellable, because, according to the documentation "An AnyCancellable instance automatically calls cancel() when deinitialized."
private var requestCancellable: AnyCancellable?
func setUpSubscription() {
requestCancellable = request
.sink(receiveCompletion: { _ in
print("subscription2 completed")
}, receiveValue: { image in
print("subscription1 value: \(image.scale)")
})
}
func cancelSubscription() {
requestCancellable = nil
}

Related

Swift Background Execution

I have a step function on AWS triggered by an HTTP Post request. The function can take a few seconds to complete. I'd like for execution to continue if the user puts the app into the background, and to correctly navigate to the next screen once the user puts the app back into the foreground (if execution has finished).
My API Client endpoint looks like this:
func connect<OutputType: Decodable>(to request: URLRequestConvertible, decoder: JSONDecoder) -> AnyPublisher<Result<OutputType, Error>, Never> {
var request = request.asURLRequest()
if let token: String = KeychainWrapper.standard.string(forKey: "apiToken") {
request.addValue(token, forHTTPHeaderField: "Authorization")
}
let configuration = URLSessionConfiguration.default
configuration.waitsForConnectivity = true
let session = URLSession(configuration: configuration)
return session.dataTaskPublisher(for: request)
.tryMap({ (data, response) -> Data in
guard let response = response as? HTTPURLResponse else { throw NetworkError.invalidResponse }
guard 200..<300 ~= response.statusCode else {
throw NetworkError.invalidStatusCode(statusCode: response.statusCode)
}
return data
})
.decode(type: OutputType.self, decoder: decoder)
.map(Result.success)
.catch { error -> Just<Result<OutputType, Error>> in Just(.failure(error)) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
I'd like to know the best practice for implementing this call. I'm currently using beginBackgroundTask below.
func makeRequest() {
DispatchQueue.global(qos: .userInitiated).async {
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask (withName: "Request Name") {
UIApplication.shared.endBackgroundTask(self.backgroundTaskID!)
self.backgroundTaskID = .invalid
}
<implementation>
}
}
However, implementation only works if I have nested DispatchQueue.main.async blocks where I perform more logic after making the HTTP request (like determining which screen to navigate to next after we receive the response.
Is this the best way to do it? Is it ok to have a few different nested DispatchQueue.main.async blocks inside the DispatchQueue.global block? Should I post the .receive(on: ) to DispatchQueue.global?
You don’t have to dispatch this background task to a background queue at all. (Don’t conflate the “background task”, which refers to the app state, with the “background queue”, which governs which threads are used.)
Besides, as the documentation says, the expiration handler closure runs on the main thread:
The system calls the handler synchronously on the main thread, blocking the app’s suspension momentarily.
So you really want to keep all interaction with backgroundTaskID on the main thread, anyway, or else you would have to implement some other synchronization mechanism.
And as a matter of good practice, make sure to end your background task when your asynchronous request is done (rather than relying on the expiration/timeout closure).

Swift - Queueing Combine Requests

I am working on a Combine request which I want to either execute or queue to execute after a certain event. Below is the scenario -
New request is generated.
Check if the app has an access token
If yes, execute the request
If no, fetch token, then execute the request
Below is my API where every request will be triggered -
public func fetchData<T: Codable>(to request: URLRequest) -> AnyPublisher<Result<T>, Error> {
if hasToken {
return self.urlSession.dataTaskPublisher(for: request)
.tryMap(self.parseJson)
.receive(on: RunLoop.main)
.subscribe(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
else {
// Store request somewhere
// Get token
// Execute stored request
}
}
I would really appreciate it if anyone can suggest how I can proceed with the else part of my code.
The approach you're starting with wouldn't work if there were multiple requests in quick succession happening. All these requests would see hasToken as being false, and they will all initiate a token request.
You could possibly create a var tokenRequested: Bool property and synchronize access to it.
The way I approached this - not sure if this is the best approach - was by creating a pipeline through which all request would be queued:
class Service {
private let requestToken = PassthroughSubject<Void, Error>()
private let tokenSubject = CurrentValueSubject<Token?, Error>(nil)
var c: Set<AnyCancellable> = []
init() {
requestToken.zip(tokenSubject)
.flatMap { (_, token) -> AnyPublisher<Token, Error> in
if let token = token {
return Just(token).setFailureType(to: Error.self)
.eraseToAnyPublisher()
} else {
return self.fetchToken()
.eraseToAnyPublisher()
}
}
.map { $0 as Token? }
.subscribe(tokenSubject)
.store(in: &c)
}
private func fetchToken() -> AnyPublisher<Token, Error> {
// async code to fetch the token
}
}
Any request via requestToken syncs with a value from tokenSubject, so the first one goes together with the initial nil, but subsequent ones wait until publishes the next value, which happens when the previous one completes.
Then, to make a request, you'd first get the token getToken(), then make the request.
extension Service {
private func getToken() -> AnyPublisher<Token, Error> {
// request token, which starts queues it in the pipeline
requestToken.send(())
// wait until next token is available
return tokenSubject
.compactMap { $0 } // non-nil token
.first() // only one
.eraseToAnyPublisher()
}
func fetchData<T: Codable>(request: URLRequest) -> AnyPublisher<T, Error> {
getToken()
.flatMap { token in
// make request
}
.eraseToAnyPublisher()
}
}

URLSession.shared.dataTask vs dataTaskPublisher, when to use which?

I recently encounter two data fetching (download) API that performs seemingly the same thing to me. I cannot see when should I use one over the other.
I can use URLSession.shared.dataTask
var tasks: [URLSessionDataTask] = []
func loadItems(tuple : (name : String, imageURL : URL)) {
let task = URLSession.shared.dataTask(with: tuple.imageURL, completionHandler :
{ data, response, error in
guard let data = data, error == nil else { return }
DispatchQueue.main.async() { [weak self] in
self?.displayFlag(data: data, title: tuple.name)
}
})
tasks.append(task)
task.resume()
}
deinit {
tasks.forEach {
$0.cancel()
}
}
Or I can use URLSession.shared.dataTaskPublisher
var cancellables: [AnyCancellable] = []
func loadItems(tuple : (name : String, imageURL : URL)) {
URLSession.shared.dataTaskPublisher(for: tuple.imageURL)
.sink(
receiveCompletion: {
completion in
switch completion {
case .finished:
break
case .failure( _):
return
}},
receiveValue: { data, _ in DispatchQueue.main.async { [weak self] in self?.displayFlag(data: data, title: tuple.name) } })
.store(in: &cancellables)
}
deinit {
cancellables.forEach {
$0.cancel()
}
}
I don't see their distinct differences, as both also can fetch, and both also provide us the ability to cancel the tasks easily. Can someone shed some light on their differences in terms of when to use which?
The first one is the classic. It has been present for quite some time now and most if not all developers are familiar with it.
The second is a wrapper around the first one and allows combining it with other publishers (e.g. Perform some request only when first two requests were performed). Combination of data tasks using the first approach would be far more difficult.
So in a gist: use first one for one-shot requests. Use second one when more logic is needed to combine/pass results with/to other publishers (not only from URLSession). This is, basically, the idea behind Combine framework - you can combine different ways of async mechanisms (datatasks utilising callbacks being one of them).
More info can be found in last year's WWDC video on introducing combine.

Memory leak when using `Publishers.Sequence`

I have a function that create collection of Publishers:
func publishers(from text: String) -> [AnyPublisher<SignalToken, Never>] {
let signalTokens: [SignalToken] = translate(from: text)
var delay: Int = 0
let signalPublishers: [AnyPublisher<SignalToken, Never>] = signalTokens.map { token in
let publisher = Just(token)
.delay(for: .milliseconds(delay), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
delay += token.delay
return publisher
}
return signalPublishers
}
In service class I have to method, one for play():
func play(signal: String) {
anyCancellable = signalTokenSubject.sink(receiveValue: { token in print(token) }
anyCancellable2 = publishers(from: signal)
.publisher
.flatMap { $0 }
.subscribe(on: DispatchQueue.global())
.sink(receiveValue: { [weak self] token in
self?.signalTokenSubject.send(token)
})
}
and one for stop():
func stop() {
anyCancellable?.cancel()
anyCancellable2?.cancel()
}
I've had problem with memory. When collection of publishers is large and I stop() before whole Publishers.Sequence is .finshed memory increase and never release.
Is there a way to completed Publishers.Sequence earlier, before Combine iterate over whole collection?
To reclaim the memory, release the pipelines:
func stop() {
anyCancellable?.cancel()
anyCancellable2?.cancel()
anyCancellable = nil
anyCancellable2 = nil
}
Actually you don't need the cancel calls, because releasing the pipelines does cancel in good order; that is the whole point of AnyCancellable. So you can just say:
func stop() {
anyCancellable = nil
anyCancellable2 = nil
}
Another thing to note is that you are running all your publishers at once. The sequence does not arrive sequentially; the whole sequence is dumped into the flapMap which starts all the publishers publishing simultaneously. Thus cancelling doesn't do you all that much good. You might want to set the maxPublishers: on your flatMap so that backpressure prevents more than some small number of publishers from arriving simultaneously (like for example one at a time).

How can I continue URLSession dataTaskPublisher or another Publisher after error?

I have an app that needs to check a status on a server:
every 30 seconds
whenever the app enters the foreground
I'm doing this by merging two publishers, then calling flatMap the merged publisher's output to trigger the API request.
I have a function that makes an API request and returns a publisher of the result, also including logic to check the response and throw an error depending on its contents.
It seems that once a StatusError.statusUnavailable error is thrown, the statusSubject stops getting updates. How can I change this behavior so the statusSubject continues getting updates after the error? I want the API requests to continue every 30 seconds and when the app is opened, even after there is an error.
I also have a few other points where I'm confused about my current code, indicated by comments, so I'd appreciate any help, explanation, or ideas in those areas too.
Here's my example code:
import Foundation
import SwiftUI
import Combine
struct StatusResponse: Codable {
var response: String?
var error: String?
}
enum StatusError: Error {
case statusUnavailable
}
class Requester {
let statusSubject = CurrentValueSubject<StatusResponse,Error>(StatusResponse(response: nil, error: nil))
private var cancellables: [AnyCancellable] = []
init() {
// Check for updated status every 30 seconds
let timer = Timer
.publish(every: 30,
tolerance: 10,
on: .main,
in: .common,
options: nil)
.autoconnect()
.map { _ in true } // how else should I do this to be able to get these two publisher outputs to match so I can merge them?
// also check status on server when the app comes to the foreground
let foreground = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
// bring the two publishes together
let timerForegroundCombo = timer.merge(with: foreground)
timerForegroundCombo
// I don't understand why this next line is necessary, but the compiler gives an error if I don't have it
.setFailureType(to: Error.self)
.flatMap { _ in self.apiRequest() }
.subscribe(statusSubject)
.store(in: &cancellables)
}
private func apiRequest() -> AnyPublisher<StatusResponse, Error> {
let url = URL(string: "http://www.example.com/status-endpoint")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared.dataTaskPublisher(for: request)
.mapError { $0 as Error }
.map { $0.data }
.decode(type: StatusResponse.self, decoder: JSONDecoder())
.tryMap({ status in
if let error = status.error,
error.contains("status unavailable") {
throw StatusError.statusUnavailable
} else {
return status
}
})
.eraseToAnyPublisher()
}
}
Publishing a failure always ends a subscription. Since you want to continue publishing after an error, you cannot publish your error as a failure. You must instead change your publisher's output type. The standard library provides Result, and that's what you should use.
func makeStatusPublisher() -> AnyPublisher<Result<StatusResponse, Error>, Never> {
let timer = Timer
.publish(every: 30, tolerance: 10, on: .main, in: .common)
.autoconnect()
.map { _ in true } // This is the correct way to merge with the notification publisher.
let notes = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
return timer.merge(with: notes)
.flatMap({ _ in
statusResponsePublisher()
.map { Result.success($0) }
.catch { Just(Result.failure($0)) }
})
.eraseToAnyPublisher()
}
This publisher emits either .success(response) or .failure(error) periodically, and never completes with a failure.
However, you should ask yourself, what happens if the user switches apps repeatedly? Or what if the API request takes more that 30 seconds to complete? (Or both?) You'll get multiple requests running simultaneously, and the responses will be handled in the order they arrive, which might not be the order in which the requests were sent.
One way to fix this would be to use flatMap(maxPublisher: .max(1)) { ... }, which makes flatMap ignore timer and notification signals while it's got a request outstanding. But it would perhaps be even better for it to start a new request on each signal, and cancel the prior request. Change flatMap to map followed by switchToLatest for that behavior:
func makeStatusPublisher2() -> AnyPublisher<Result<StatusResponse, Error>, Never> {
let timer = Timer
.publish(every: 30, tolerance: 10, on: .main, in: .common)
.autoconnect()
.map { _ in true } // This is the correct way to merge with the notification publisher.
let notes = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
return timer.merge(with: notes)
.map({ _ in
statusResponsePublisher()
.map { Result<StatusResponse, Error>.success($0) }
.catch { Just(Result<StatusResponse, Error>.failure($0)) }
})
.switchToLatest()
.eraseToAnyPublisher()
}
You can use retry() to get this kind of behaviour or catch it...more info here:
https://www.avanderlee.com/swift/combine-error-handling/

Resources