Search code examples
swiftcombinepublisher

Custom Combine Publisher wrapper class does not work unless retained


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.


Solution

  • 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.