Cleaning up observables - swift

I am using rxSwift and I have a dictionary of observables which can be subscribed to.
var observables: [String: Observable<Bool>] = [:]
At some point I have to clean up some of those observables. I do it as follows
observables.removeValue(forKey: someKey)
Is it enough to clean up the observables with the above line? Is the observable also killed (or how do I "kill" it)? Someone might already be subscribed to the observable and then even after removing it from the dictionary it would still be alive and might fire, right?
Or is the observable gone the moment I remove it because nobody holds a strong reference to it? What happens in the moment the observable is removed to potential subsribers?
I do not have access to the subscribers from the class where the dictionary with the observables is kept.

You can use takeUntil operator. It will send a completed signal to the observable, so the subscriber will release the retained resources.
For example, you can setup a PublishSubject where you send the observable identifier to complete that observable.
var observables: [String: Observable<Bool>] = [:]
let finishObservable = PublishSubject<String>()
func addObservable(observable: Observable<Bool>, withId identifier: String) -> Observable<Bool> {
let condition = finishObservable.filter { $0 == identifier }
let newObservable = observable.takeUntil(condition)
observables[identifier] = newObservable
return newObservable
}
This way, to clean an observable, you send the observable identifier and then you can remove the completed sequence from the dictionary.
func removeObservable(identifier: String) {
// Complete the observable so it stops sending events and subscriber releases resources
finishObservable.onNext(identifier)
observables.removeValue(forKey: identifier)
}
If you're planning to share subscription between observers, you can also use a ConnectableObservable. I've used this kind of observables when subscribers come and go but you want to share the same subscription. It's usefull if the observable fetches network resources for example.
var disposables: [String: Disposable] = [:]
func addObservable(observable: Observable<Bool>, withId identifier: String) -> Observable<Bool> {
let newObservable: ConnectableObservable = observable.replay(1)
disposables[identifier] = newObservable.connect() // This call triggers the subscription, so you can call it later
return newObservable
}
func removeObservable(identifier: String) {
if let disposable = disposables.removeValue(forKey: identifier) {
disposable.dispose()
}
}

Related

How to chain together two Combine publishers in Swift and keep the cancellable object

Does anyone know how to chain together two publishers with Swift + Combine and keep the cancellable so that I can cancel the pipeline later?
I have a method that accepts input from a publisher and outputs to a publisher:
static func connect(inputPublisher: Published<String>.Publisher, outputPublisher: inout Published<String>.Publisher) -> AnyCancellable {
inputPublisher.assign(to: &outputPublisher)
// Unfortunately assign consumes cancellable
}
Note that this method cannot access the wrapped properties directly. The publishers must be passed as arguments.
If I understand correctly, you want to link two publishers but with the option to break that link at some point in the future.
I would try using sink on the inputPublisher, since that function gives me a cancellable, and then a PassthroughSubject, since I wasn't able to figure out how to pass the value from sink directly to outputPublisher.
It would look something like this:
static func connect(inputPublisher: Published<String>.Publisher, outputPublisher: inout Published<String>.Publisher) -> AnyCancellable {
let passthrough = PassthroughSubject<String, Never>()
passthrough.assign(to: &outputPublisher)
let cancellable = inputPublisher.sink { string in
passthrough.send(string)
}
return cancellable
}
Disclaimer: I wrote this on a Playground and it compiles, but I didn't actually run it.
You cannot do that with assign(to:), since it binds the lifetime of the subscription to the lifetime of the Published upstream (inputPublisher in your case) and hence you cannot manually cancel the subscription.
Instead, you can use assign(to:on:), which takes an object and a key path and whenever the upstream emits, it assigns the value to the property represented by the key path on the object.
inputPublisher.assign(to:on:)
class PublishedModel {
#Published var value: String
init(value: String) {
self.value = value
}
}
let outputModel = PublishedModel(value: "")
// this returns an `AnyCancellable` that you can cancel
PublishedModel(value: "initial").$value.assign(to: \.value, on: outputModel)
You can also create a convenience method for this
func assign<Root, Value>(published: Published<Value>.Publisher, to keyPath: ReferenceWritableKeyPath<Root, Value>, on object: Root) -> AnyCancellable {
published.assign(to: keyPath, on: object)
}
let outputModel = PublishedModel(value: "")
let inputModel = PublishedModel(value: "initial")
assign(published: inputModel.$value, to: \.value, on: outputModel)

Store subscription from custom Combine subscriber?

According to the swift combine documentation, the proper way to connect a subscriber to a publisher is with the publishers subscribe<S>(S) protocol extension method. Then the publisher creates a subscription and passes that subscription on to the subscriber. All's well and good so far.
What I can't seem to figure out is how to gain access to that subscription and retain it in the calling code. How is sink() implemented such that it can return that subscription? Unless I'm mistaken, the subscription is responsible for retaining its subscriber, which means I can't store a reference to the subscription in the subscriber. And since Subscription isn't class bound, it can't be weak.
Here's an example of how sink might be implemented
import Combine
import Foundation
extension Publisher {
func mockSink(
receiveValue: #escaping (Output) -> Void,
receiveCompletion: #escaping (Subscribers.Completion<Failure>) -> Void) -> AnyCancellable {
var result : AnyCancellable!
let anySubscriber = AnySubscriber(
receiveSubscription: { subscription in
subscription.request(.unlimited)
result = AnyCancellable({subscription.cancel()})
},
receiveValue: { (value : Output) in receiveValue(value); return .unlimited},
receiveCompletion: receiveCompletion)
subscribe(anySubscriber)
return result
}
}
var subscriptions = Set<AnyCancellable>()
["one", "two", "three"].publisher
.mockSink(receiveValue: {debugPrint($0)}, receiveCompletion: {debugPrint($0)})
.store(in: &subscriptions)
As you can see the ability to return an AnyCancellable arises from the magic of AnySubscriber which returns its subscription in a closure. AnySubscriber is a handy tool to have when implementing custom Publishers and it may be helpful if you want to implement your own Subscriber type. But basically a Subscriber only exposes the subscription if it is designed to.
The cancellable retains the subscription (or possibly is the subscription. Remember Subscriptions are Cancellables.) So when you retain the Cancellable, you are retaining the subscription.

Swift Combine erase array of publishers into AnyCancellable

Is it possible to fire multiple requests which return a Publisher and be able to cancel them without sink?
I would like to combine the requests into a single cancellable reference or store each one if possible without sink (code below). Is this possible?
func fetchDetails(for contract: String) -> AnyPublisher<String, Error>
Fire Multiple requests and store
#State var cancellable: Set<AnyCancellable> = []
let promises = items.map {
self.fetchFeed.fetchDetails(for: $0.contract)
}
Publishers.MergeMany(promises)
.sink(receiveCompletion: { _ in }, receiveValue: { _ in }) // ** is this required?
.store(in: &cancellable)
It really depends on what fetchDetails does to create the publisher. Almost every publisher provided by Apple has no side effects until you subscribe to it. For example, the following publishers have no side effects until you subscribe to them:
NSObject.KeyValueObservingPublisher (returned by NSObject.publisher(for:options:)
NotificationCenter.Publisher (returned by NotificationCenter.publisher(for:object:)
Timer.TimerPublisher (returned by Timer.publishe(every:tolerance:on:in:options:)
URLSession.DataTaskPublisher (returned by URLSession.dataTaskPublisher(for:)
The synchronous publishers like Just, Empty, Fail, and Sequence.Publisher.
In fact, the only publisher that has side effects on creation, as far as I know, is Future, which runs its closure immediately on creation. This is why you'll often see the Deferred { Future { ... } } construction: to avoid immediate side effects.
So, if the publisher returned by fetchDetails behaves like most publishers, you must subscribe to it to make any side effects happen (like actually sending a request over the network).

In a Combine Publisher chain, how to keep inner objects alive until cancel or complete?

I've created a Combine publisher chain that looks something like this:
let pub = getSomeAsyncData()
.mapError { ... }
.map { ... }
...
.flatMap { data in
let wsi = WebSocketInteraction(data, ...)
return wsi.subject
}
.share().eraseToAnyPublisher()
It's a flow of different possible network requests and data transformations. The calling code wants to subscribe to pub to find out when the whole asynchronous process has succeeded or failed.
I'm confused about the design of the flatMap step with the WebSocketInteraction. That's a helper class that I wrote. I don't think its internal details are important, but its purpose is to provide its subject property (a PassthroughSubject) as the next Publisher in the chain. Internally the WebSocketInteraction uses URLSessionWebSocketTask, talks to a server, and publishes to the subject. I like flatMap, but how do you keep this piece alive for the lifetime of the Publisher chain?
If I store it in the outer object (no problem), then I need to clean it up. I could do that when the subject completes, but if the caller cancels the entire publisher chain then I won't receive a completion event. Do I need to use Publisher.handleEvents and listen for cancellation as well? This seems a bit ugly. But maybe there is no other way...
.flatMap { data in
let wsi = WebSocketInteraction(data, ...)
self.currentWsi = wsi // store in containing object to keep it alive.
wsi.subject.sink(receiveCompletion: { self.currentWsi = nil })
wsi.subject.handleEvents(receiveCancel: {
wsi.closeWebSocket()
self.currentWsi = nil
})
Anyone have any good "design patterns" here?
One design I've considered is making my own Publisher. For example, instead of having WebSocketInteraction vend a PassthroughSubject, it could conform to Publisher. I may end up going this way, but making a custom Combine Publisher is more work, and the documentation steers people toward using a subject instead. To make a custom Publisher you have to implement some of things that the PassthroughSubject does for you, like respond to demand and cancellation, and keep state to ensure you complete at most once and don't send events after that.
[Edit: to clarify that WebSocketInteraction is my own class.]
It's not exactly clear what problems you are facing with keeping an inner object alive. The object should be alive so long as something has a strong reference to it.
It's either an external object that will start some async process, or an internal closure that keeps a strong reference to self via self.subject.send(...).
class WebSocketInteraction {
private let subject = PassthroughSubject<String, Error>()
private var isCancelled: Bool = false
init() {
// start some async work
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !isCancelled { self.subject.send("Done") } // <-- ref
}
}
// return a publisher that can cancel the operation when
var pub: AnyPublisher<String, Error> {
subject
.handleEvents(receiveCancel: {
print("cancel handler")
self.isCancelled = true // <-- ref
})
.eraseToAnyPublisher()
}
}
You should be able to use it as you wanted with flatMap, since the pub property returned publisher, and the inner closure hold a reference to self
let pub = getSomeAsyncData()
...
.flatMap { data in
let wsi = WebSocketInteraction(data, ...)
return wsi.pub
}

How to properly pull from cache before remote using swift combine

I perform many repeated requests in order to populate a field. I would like to cache the result and use the cached value the next time around.
public func getItem(_ id: String) -> AnyPublisher<Item?, Never> {
if let item = itemCache[id] {
return Just(item).eraseToAnyPublisher()
}
return downloadItem(id: id)
.map { item in
if let item = item {
itemCache[id] = item
}
return item
}
.eraseToAnyPublisher()
}
}
func downloadItem(_ id: String) -> AnyPublisher<Item?, Never> { ... }
And this is called like this:
Just(["a", "a", "a"]).map(getItem)
However, all the requests are calling downloadItem. downloadItem does return on the main queue. I also tried wrapping the entire getItem function into Deferred but that had the same result.
First, the issue was that the function is being evaluated and only a publisher is returned. So the cache check is evaluated each time before the network publisher is ever subscribed to. Using Deferred is the proper fix for that. However, that still didn't solve the problem.
The solution was instead to first cache a shared publisher while the network request is pending so all requests during the network call will use the same publisher, then when it's complete to cache a Just publisher for the all future calls:
public func getItem(_ id: String) -> AnyPublisher<Item?, Never> {
if let publisher = self.publisherCache[id] {
return publisher
}
let publisher = downloadItem(id)
.handleEvents(receiveOutput: {
// Re-cache a Just publisher once the network request finishes
self.publisherCache[id] = Just($0).eraseToAnyPublisher()
})
.share() // Ensure the same publisher is returned from the cache
.eraseToAnyPublisher()
// Cache the publisher to be used while downloading is in progress
self.publisherCache[id] = publisher
return publisher
}
One note, is that downloadItem(id) is async and being recieved on the main loop. When I replaced downloadItem(id) with Just(Item()) for testing, this didn't work beause the entire publisher chain was evaluated on creation. Use Just(Item()).recieve(on: Runloop.main) to fix that while testing.