Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to wrap the delegate pattern with a one-shot publisher?

Tags:

ios

swift

combine

Normally we can bridge our async code and Combine by wrapping our async code in a single-shot publisher using a Future:

func readEmail() -> AnyPublisher<[String], Error> {
  Future { promise in
    self.emailManager.readEmail() { result, error in
      if let error = error {
        promise(.failure(error))
      } else {
        promise(.success(result))
      }
    }
  }.eraseToAnyPublisher()
}

On the other hand, if we're wrapping the delegate pattern (instead of an async callback), it's recommended to use a PassthroughSubject, since the methods could be fired multiple times:

final class LocationHeadingProxy: NSObject, CLLocationManagerDelegate {

  private let headingPublisher: PassthroughSubject<CLHeading, Error> 

  override init() {
    headingPublisher = PassthroughSubject<CLHeading, Error>()
    // ...
  }

  func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
    headingPublisher.send(newHeading) 
  }
}

However, I'm trying to create a one-shot publisher which wraps an existing Delegate pattern. The reason is that I'm firing off a method like connect() and I expect either a success or failure to happen immediately. I do not want future updates to affect the pipeline.

For example, imagine I'm using WKExtendedRuntimeSession and wrapped the .start() method in startSession() below. If I have this wrapped successfully, I should be able to use it like so:

manager.startSession()
  .sink(
    receiveCompletion: { result in
      if result.isError {
        showFailureToStartScreen()
      }
    },
    receiveValue: { value in
      showStartedSessionScreen()
    })
  .store(in: &cancellables)

The reason a one-shot publisher is useful is because we expect one of the following two methods to be called soon after calling the method:

  • Success: extendedRuntimeSessionDidStart(_:)
  • Fail: extendedRuntimeSession(_:didInvalidateWith:error:)

Furthermore, when the session is halted (or we terminate it ourselves), we don't want side effects such as showFailureToStartScreen() to randomly happen. We want them to be handled explicitly elsewhere in the code. Therefore, having a one-shot pipeline is beneficial here so we can guarantee that sink is only called once.


I realize that one way to do this is to use a Future, store a reference to the Promise, and call the promise at a later time, but this seems hacky at best:

class Manager: NSObject, WKExtendedRuntimeSessionDelegate {
  var session: WKExtendedRuntimeSession?
  var tempPromise: Future<Void, Error>.Promise?

  func startSession() -> AnyPublisher<Void, Error> {
    session = WKExtendedRuntimeSession()
    session?.delegate = self
    return Future { promise in
      tempPromise = promise
      session?.start()
    }.eraseToAnyPublisher()
  }

  func extendedRuntimeSessionDidStart(_ extendedRuntimeSession: WKExtendedRuntimeSession) {
    tempPromise?(.success(()))
    tempPromise = nil
  }

  func extendedRuntimeSession(_ extendedRuntimeSession: WKExtendedRuntimeSession, didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason, error: Error?) {
    if let error = error {
      tempPromise?(.failure(error))
    }
    tempPromise = nil
  }
}

Is this really the most elegant way to work with delegates + one-shot publishers, or is there a more elegant way to do this in Combine?


For reference, PromiseKit also has a similar API to Future.init. Namely, Promise.init(resolver:). However, PromiseKit also seems to natively support the functionality I describe above with their pending() function (example):

  func startSession() -> Promise {
    let (promise, resolver) = Promise.pending()
    tempPromiseResolver = resolver

    session = WKExtendedRuntimeSession()
    session?.delegate = self
    session?.start()

    return promise
  }
like image 819
Senseful Avatar asked Oct 21 '25 17:10

Senseful


1 Answers

You can ensure a one-shot publisher with .first() operator:

let subject = PassthroughSubject<Int, Never>()

let publisher = subject.first()

let c = publisher.sink(receiveCompletion: {
    print($0)
}, receiveValue: {
    print($0)
})

subject.send(1)
subject.send(2)

The output would be:

1
finished
like image 71
New Dev Avatar answered Oct 23 '25 07:10

New Dev



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!