0

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.

Yogurt
  • 2,913
  • 2
  • 32
  • 63

2 Answers2

1

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.

Rob C
  • 4,877
  • 1
  • 11
  • 24
  • I'm planning on doing a bunch of flat map, do you think there's a way to get around the way these PublisherWrappers are retained? – Yogurt Nov 05 '21 at 17:15
  • I should have made it more clear that the retain problem was the main issue. – Yogurt Nov 05 '21 at 17:16
  • I don't think it's possible with reference types – Rob C Nov 05 '21 at 22:38
  • 1
    @Biclops I take that back. This bothered me so I sat down and came up with a solution that works without needing a reference to the Publisher. I've updated my answer with the working solution. – Rob C Nov 06 '21 at 04:02
  • I need to learn more about subscriptions. Thanks. – Yogurt Nov 08 '21 at 08:32
0

Another example:

import Combine
import GoogleCast

struct GCKRequestPublisher: Publisher {
    typealias Output = Void
    typealias Failure = Error

    let request: GCKRequest

    init(request: GCKRequest) {
        self.request = request
    }

    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
        let subscription = GCKRequestSubscription(request: request, subscriber: subscriber)
        subscriber.receive(subscription: subscription)
    }
}

class GCKRequestSubscription<S: Subscriber>: NSObject, GCKRequestDelegate, Subscription where S.Input == Void, S.Failure == Error {
    var subscriber: S?
    let request: GCKRequest

    enum GCKRequestError: Error {
        case error(GCKError)
        case abort(GCKRequestAbortReason)
    }
    
    init(request: GCKRequest, subscriber: S) {
        self.subscriber = subscriber
        self.request = request
        super.init()
        self.request.delegate = self
    }

    func request(_ demand: Subscribers.Demand) { }

    func cancel() {
        request.cancel()
        subscriber = nil
    }

    // MARK: - GCKRequestDelegate
    func requestDidComplete(_ request: GCKRequest) {
        subscriber?.receive(completion: .finished)
    }

    func request(_ request: GCKRequest, didFailWithError error: GCKError) {
        subscriber?.receive(completion: .failure(GCKRequestError.error(error)))
    }

    func request(_ request: GCKRequest, didAbortWith abortReason: GCKRequestAbortReason) {
        subscriber?.receive(completion: .failure(GCKRequestError.abort(abortReason)))
    }
}
markturnip
  • 405
  • 2
  • 7