How to replicate PromiseKit-style chained async flow using Combine + Swift

做~自己de王妃 提交于 2020-05-13 06:59:55

问题


I was using PromiseKit successfully in a project until Xcode 11 betas broke PK v7. In an effort to reduce external dependencies, I decided to scrap PromiseKit. The best replacement for handling chained async code seemed to be Futures using the new Combine framework.

1) Is this dumb?

2) I am struggling to replicate the simple PK syntax using Combine

ex. simple PromiseKit chained async call syntax

getAccessCodeFromSyncProvider.then{accessCode in startSync(accessCode)}.then{popToRootViewController}.catch{handleError(error)}

Concepts from Combine I understand: Publishers, Subscribers, Subjects, sink method, error handling, completion vs. error vs. value

What I struggle with is how to patch it all together to make simple Do This, then Do This

I understand:

A Swift standard library implementation of async/await would solve this problem (async/await does not yet exist, despite lots of chatter and involvement from Chris Latter himself)

I could replicate using Semaphores (error-prone?)

flatMap can be used to chain Futures (I can't work out the syntax in order to embed into a method that can be called on-demand to fire-off a chain of async calls.)

Combine is different from PromiseKit. (I am simply trying to chain simple bits of async code, at the highest-level of abstraction possible, using Swift standard library.)

The async code I'd like should be able to be called on demand, since it's involved with ensuring user is logged in. I'm wrestling with two conceptual problems. 1) If I wrap Futures in a method, with sink to handle result, it seems that the method goes out of scope before subscriber is called by sink.

2) Since Futures execute only once, I worry that if I call the method multiple times I'll only get the old, stale, result from the first call. To work around this, maybe I would use a PassthroughSubject? This allows the Publisher to be called on demand.

I'm lost on the syntax of an instance method that can be called to fire off chained async code, with fresh results each time. Maybe Combine is overkill? Is there an easier way, using Swift standard library?

Questions:

  1. Do I have to retain every publisher and subscriber outside of the calling method
  2. How can I replicate simple chained async using the Swift standard library and then embed this in a swift instance method I can call on-demand to restart the chained async calls from the top??
//how is this done using Combine (*or is Combine overkill*)?
func startSync() {
 getAccessCodeFromSyncProvider.then{accessCode in startSync(accessCode)}.catch{\\handle error here}
}

回答1:


This is not a real answer to your whole question — only to the part about how to get started with Combine. I'll demonstrate how to chain two asynchronous operations using the Combine framework:

    print("start")
    Future<Bool,Error> { promise in
        delay(3) {
            promise(.success(true))
        }
    }
    .handleEvents(receiveOutput: {_ in print("finished 1")})
    .flatMap {_ in
        Future<Bool,Error> { promise in
            delay(3) {
                promise(.success(true))
            }
        }
    }
    .handleEvents(receiveOutput: {_ in print("finished 2")})
    .sink(receiveCompletion: {_ in}, receiveValue: {_ in print("done")})
        .store(in:&self.storage) // storage is a persistent Set<AnyCancellable>

First of all, the answer to your question about persistence is: the final subscriber must persist, and the way to do this is using the .store method. Typically you'll have a Set<AnyCancellable> as a property, as here, and you'll just call .store as the last thing in the pipeline to put your subscriber in there.

Next, in this pipeline I'm using .handleEvents just to give myself some printout as the pipeline moves along. Those are just diagnostics and wouldn't exist in a real implementation. All the print statements are purely so we can talk about what's happening here.

So what does happen?

start
finished 1 // 3 seconds later
finished 2 // 3 seconds later
done

So you can see we've chained two asynchronous operations, each of which takes 3 seconds.

How did we do it? We started with a Future, which must call its incoming promise method with a Result as a completion handler when it finishes. After that, we used .flatMap to produce another Future and put it into operation, doing the same thing again.

So the result is not beautiful (like PromiseKit) but it is a chain of async operations.

Before Combine, we'd have probably have done this with some sort of Operation / OperationQueue dependency, which would work fine but would have even less of the direct legibility of PromiseKit.

Slightly more realistic

Having said all that, here's a slightly more realistic rewrite:

var storage = Set<AnyCancellable>()
func async1(_ promise:@escaping (Result<Bool,Error>) -> Void) {
    delay(3) {
        print("async1")
        promise(.success(true))
    }
}
func async2(_ promise:@escaping (Result<Bool,Error>) -> Void) {
    delay(3) {
        print("async2")
        promise(.success(true))
    }
}
override func viewDidLoad() {
    print("start")
    Future<Bool,Error> { promise in
        self.async1(promise)
    }
    .flatMap {_ in
        Future<Bool,Error> { promise in
            self.async2(promise)
        }
    }
    .sink(receiveCompletion: {_ in}, receiveValue: {_ in print("done")})
        .store(in:&self.storage) // storage is a persistent Set<AnyCancellable>
}

As you can see, the idea that is our Future publishers simply have to pass on the promise callback; they don't actually have to be the ones who call them. A promise callback can thus be called anywhere, and we won't proceed until then.

You can thus readily see how to replace the artificial delay with a real asynchronous operation that somehow has hold of this promise callback and can call it when it completes. Also my promise Result types are purely artificial, but again you can see how they might be used to communicate something meaningful down the pipeline. When I say promise(.success(true)), that causes true to pop out the end of the pipeline; we are disregarding that here, but it could be instead a downright useful value of some sort, possibly even the next Future.

(Note also that we could insert .receive(on: DispatchQueue.main) at any point in the chain to ensure that what follows immediately is started on the main thread.)

Slightly neater

It also occurs to me that we could make the syntax neater, perhaps a little closer to PromiseKit's lovely simple chain, by moving our Future publishers off into constants. If you do that, though, you should probably wrap them in Deferred publishers to prevent premature evaluation. So for example:

var storage = Set<AnyCancellable>()
func async1(_ promise:@escaping (Result<Bool,Error>) -> Void) {
    delay(3) {
        print("async1")
        promise(.success(true))
    }
}
func async2(_ promise:@escaping (Result<Bool,Error>) -> Void) {
    delay(3) {
        print("async2")
        promise(.success(true))
    }
}
override func viewDidLoad() {
    print("start")
    let f1 = Deferred{Future<Bool,Error> { promise in
        self.async1(promise)
    }}
    let f2 = Deferred{Future<Bool,Error> { promise in
        self.async2(promise)
    }}
    // this is now extremely neat-looking
    f1.flatMap {_ in f2 }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: {_ in}, receiveValue: {_ in print("done")})
        .store(in:&self.storage) // storage is a persistent Set<AnyCancellable>
}


来源:https://stackoverflow.com/questions/59428026/how-to-replicate-promisekit-style-chained-async-flow-using-combine-swift

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!