I wrote a Combine Publisher wrapper class for some old code that used delegation.
TLDR; Can someone improve how I manage the lifetime of my custom publisher. Preferrable by making it behave like normal publishers, where you can just sink to it and not have to worry about retaining that instance.
Details I encountered a problem where I have to keep a reference to my Publisher wrapper for it to work. Every example of a custom publisher doesn't have this requirement, though their publishers were structs and were fairly different from mine.
Here's a simplified version of the problem that I'm having. Note the commented out section in doSomething()
import Foundation
import Combine
// Old code that uses delegate
protocol ThingDelegate: AnyObject {
func delegateCall(number: Int)
}
class Thing {
weak var delegate: ThingDelegate?
var name: String = "Stuff"
init() {
Swift.print("thing init")
}
deinit {
Swift.print("☠️☠️☠️☠️☠️☠️ thing deinit")
}
func start() {
Swift.print("Thing.start()")
DispatchQueue.main.async {
self.delegate?.delegateCall(number: 99)
}
}
}
// Combine Publisher Wrapper
class PublisherWrapper: Publisher {
typealias Output = Int
typealias Failure = Error
private let subject = PassthroughSubject<Int, Failure>()
var thing: Thing
init(thing: Thing) {
Swift.print("wrapper init")
self.thing = thing
self.thing.delegate = self
}
deinit {
Swift.print("☠️☠️☠️☠️☠️☠️ wrapper deinit")
}
func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Int == S.Input {
self.subject.subscribe(subscriber)
self.thing.start()
}
}
extension PublisherWrapper: ThingDelegate {
func delegateCall(number: Int) {
Swift.print("publisher delegate call: \(number)")
self.subject.send(number)
self.subject.send(completion: .finished)
}
}
class Test {
var cancellables = Set<AnyCancellable>()
var wrapper: PublisherWrapper?
func doSomething() {
Swift.print("doSomething()")
let thing = Thing()
let wrapper = PublisherWrapper(thing: thing)
self.wrapper = wrapper
// Take a look over here
//
// if you comment out the line above where I set self.wrapper = wrapper
// it prints out the following
//
//start
//doSomething()
//thing init
//wrapper init
//Thing.start()
//☠️☠️☠️☠️☠️☠️ wrapper deinit
//☠️☠️☠️☠️☠️☠️ thing deinit
//
// But if you uncomment the line and retain it and you'll get the following
//start
//doSomething()
//thing init
//wrapper init
//Thing.start()
//publisher delegate call: 99
//value: 99
//finished
//release wrapper: nil
//☠️☠️☠️☠️☠️☠️ wrapper deinit
//☠️☠️☠️☠️☠️☠️ thing deinit
// we get the value and everything works as it should
wrapper.sink { [weak self] completion in
print(completion)
self?.wrapper = nil
print("release wrapper: \(self?.wrapper)")
} receiveValue: {
print("value: \($0)")
}.store(in: &self.cancellables)
}
}
print("start")
let t = Test()
t.doSomething()
Is there an approach that avoids retaining the publisher like this? I ask because this can get pretty ugly when using flatMap.
One solution is to implement a custom Subscription
object.
I'd implement a simple protocol that you can conform to for all of your classes that have delegates.
protocol Provider {
associatedtype Output
func start(provide: @escaping (Output) -> Void)
}
Here I've implemented a Publisher
that I can feed the Provider
. All the publisher really does is create a Subscription
object and connect it to the Subscriber
that's passed into the receive(subscriber:)
method. The custom Subscription
object does all the heavy lifting. As such, we can define our Publisher
as a struct.
The Subscription
object receives data from the Provider
and passes it down to the Subscriber
. Notice that the Subscription
object needs to save a reference to the Provider
so it isn't deallocated.
extension Publishers {
struct Providable<ProviderType: Provider>: Publisher {
typealias Output = ProviderType.Output
typealias Failure = Never
private class Subscription<SubscriberType: Subscriber>: Combine.Subscription {
private let provider: ProviderType
init(
provider: ProviderType,
subscriber: SubscriberType
) where SubscriberType.Input == ProviderType.Output {
self.provider = provider
provider.start { value in
_ = subscriber.receive(value)
subscriber.receive(completion: .finished)
}
}
deinit {
Swift.print("provided subscription deinit")
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {}
}
private let provider: ProviderType
init(provider: ProviderType) {
self.provider = provider
}
func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
subscriber.receive(subscription: Subscription(provider: provider, subscriber: subscriber))
}
}
}
The downside is that you need to implement a custom Provider
object for every delegating object that you want to wrap with Combine
:
final class ThingOutputProvider: Provider, ThingDelegate {
private let thing: Thing
private var provide: (Int) -> Void = { _ in }
init(thing: Thing) {
self.thing = thing
self.thing.delegate = self
}
func start(provide: @escaping (Int) -> Void) {
self.provide = provide
self.thing.start()
}
func delegateCall(number: Int) {
provide(number)
}
}
Here's a handy little protocol extension so we can create publishers for our Provider
:
extension Provider {
var publisher: Publishers.Providable<Self> {
Publishers.Providable(provider: self)
}
}
Usage is as follows:
class Test {
var cancellables = Set<AnyCancellable>()
func doSomething() {
ThingOutputProvider(thing: Thing())
.publisher
.sink { [weak self] completion in
print("completion: \(completion)")
self?.cancellables.removeAll()
} receiveValue: {
print("value: \($0)")
}.store(in: &self.cancellables)
}
}
The reason this works without having to maintain a reference to the Publisher
is that the Subscription
object stays alive for the lifetime of the Combine
pipeline.
Hope this helps.