swiftasynchronousswift-concurrency

How can I make my method async when it calls a library method requiring a completion?


In my app I'm using a third party library, AppGuard, to log users in. To do so the library has a method that takes a completion. Recently I got the suggestion to convert it to async/await as actors aren't compatible with the old threading practices.

Without async/await my login method looks like this:

func login() {
    // Set the necessary configuration like client ID and redirect URI.
    // The config is also loaded.

    // performs authentication request
    self.appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: scene!.keyWindow!.rootViewController!) { authState, error in
        if let authState = authState {
            self.setAuthState(authState)
            print("Got authorization tokens!")
            self.retrieveCookies()
        } else {
            print("Authorization error: \(error?.localizedDescription ?? "Unknown error")")
            self.setAuthState(nil)
        }
    }
}

In an attempt to use async/await I've rewritten it to this:

func login() async {
    // Set the necessary configuration like client ID and redirect URI.
    // The config is also loaded.

    // performs authentication request
    await withCheckedContinuation { continuation in
        self.appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: scene!.keyWindow!.rootViewController!) { authState, error in
            if let authState = authState {
                self.setAuthState(authState)
                print("Got authorization tokens!")
                self.retrieveCookies()
            } else {
                print("Authorization error: \(error?.localizedDescription ?? "Unknown error")")
                self.setAuthState(nil)
            }
            continuation.resume()
        }
    }
}

Is this the correct way to use async/await when making use of a method that takes a completion, a method you can't refactor as it comes from a library?

And what would be the advantages when using async/await in this case instead of keeping it in the first form?


Solution

  • Your attempt goes into the right direction. For the sake of readability, I'd recommend not doing anything other than calling the continuation in the callback handler, though, roughly like this:

    func login() async {
        // Set the necessary configuration like client ID and redirect URI.
        // The config is also loaded.
    
        guard let authState = await authState(byPresenting: request, presenting: scene!.keyWindow!.rootViewController!) else {
            self.setAuthState(nil)
            return
        }
        self.setAuthState(authState)
        self.retrieveCookies()
    }
    
    /// performs authentication request
    @MainActor // probably need to ensure we're on the MainActor, because a view controller is involved - might be unnecessary, if OIDAuthState.authState(byPresenting:presenting:) somehow makes sure that the view controller is called on the main thread only
    func authState(byPresenting request: Request, presenting viewController: UIViewController) async -> AuthState? {
        await withCheckedContinuation { continuation in
            self.appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: viewController) { authState, error in 
                if let authState {
                    print("Got authorization tokens!")
                    continuation.resume(returning: authState)
                } else {
                    print("Authorization error: \(error?.localizedDescription ?? "Unknown error")")
                    continuation.resume(returning: nil)
                }
            }
        }
    }
    

    You could also think about using withThrowingContinuation, make the function async throws, and throw the provided error in the callback, then handle it at the call site, depending on what's best in your individual situation. I'd probably recommend doing that, but because the provided error is apparently optional, you'd need some kind of handling to tell what happens when both, authState and error are nil, so I skipped it here, for the sake of simplicity.

    I'd also like to respond to your comment above:

    It feels easy to make a mistake somewhere and I don't really understand what the advantage is to writing the method with the continuation.

    The main advantage over callbacks (for me) is readability. While it may not make too much of a difference for a single callback based function call, imagine nested callbacks, when multiple async calls depend on each other and need to be run sequentially. Now compare that to simply using await on each line. Your code will still be just as readable, as if the calls were not async at all. With strict concurrency and Swift 6, more advantages in terms of compile-time safety for possible data races will come. Also the @MainActor attribute makes sure UI resources can only be accessed on the main thread, which again can prevent runtime issues at compile-time.