Using Swift's new async/await when invoking Kotlin Multiplatform code

Share on:

One of the biggest, and probably most anticipated, announcement at WWDC last week was regarding Swift 5.5’s new concurrency features and, in particular, its new async/await mechanism. This would appear to work in a way that’s very similar to Kotlin Coroutines which should I believe make Swift based async development significantly more approachable for Kotlin developers but also might provide option to allow a more seamless integration between Swift code and Kotlin suspend functions exposed from KMM shared code. This post is primarily aimed right now at capturing notes around exploration of using async/await mechanism to wrap/invoke calls to shared Kotlin code and also to look at what would be involved in potentially propagating cancellations in Swift async code to underlying Kotlin Coroutines. Have more questions than answers right now but hopefully that will change and will update this post if/when it does. (Update: see note at end of post).



Current mechanism to invoke Kotlin suspend functions from Swift

Kotlin 1.4 introduced the ability to effectively call Kotlin suspend functions directly from Swift. Swift of course knows nothing about Kotlin Coroutines or suspend functions so this is done by generaring code such that the method appears to the Swift code as one that’s passed a completion handler that’s invoked with success/failure info (with Kotlin/Native providing the associated mapping under the hood). For example, if you had say following function in shared Kotlin repository class (based on PeopleInSpace project)

@Throws(Exception::class)
suspend fun fetchPeople(): List<Assignment> {
    val people = peopleInSpaceApi.fetchPeople().people
    return people
}

Then this would be invoked from Swift using something like:

repository.fetchPeople { data, error in
    if let people = data {
        self.people = people
    }
    if let errorReal = error {
       // handle error
    }
}

This is definitely a useful capability to have but, as outlined in this article, has a number of limitations including for example fact that we have no control over the coroutine scope/dispatcher used and also no way to cancel the coroutine (more on that later!).

Using async/await

So, with this in place, an initial step might be to at least wrap this invocation in such a way that it at least appears to other Swift code as an async function. A Swift async function btw is described as follows (sound familiar? :) ):

An asynchronous function or asynchronous method is a special kind of function or method that can be suspended while it’s partway through execution.

The way we can achieve this mapping is by using withCheckedContinuation as shown below.

func getPeopleAsync() async -> [Assignment] {
    return await withCheckedContinuation{ continuation in
        repository.fetchPeople { data, error in
            if let people = data {
                continuation.resume(returning: people)
            }
            if let errorReal = error {
                continuation.resume(throwing: mapError(errorReal))
            }
        }
    }
}

Which can then be invoked for example like this:

async {
    self.people = await getPeopleAsync()
}

Cancellation propagation

Also announced at WWDC were some async related updates to SwiftUI. These include for example new task closure that allows invoking async functions directly and that is fired when a view appears and automatically cancelled when the view disappears (somewhat analogous to the behaviour of lifecycle aware coroutine scopes that are part of Android Fragments/ViewModels etc). Also added was automatic “pull to refresh” capability that can be added by adding refreshable which again also allows invoking async functions directly long with associated cancellation behaviour.

var body: some View {
    NavigationView {
    	List(viewModel.people, id: \.name) { person in
            NavigationLink(destination: PersonDetailsView(person: person)) {
                PersonView(viewModel: viewModel, person: person)
            }
        }
        .refreshable {
            await viewModel.getPeople()
        }
        .navigationBarTitle(Text("People In Space"))
    }.task {
        await viewModel.getPeople()
    }
}

What we’d ideally like to do is get notified of cancellation and propagate that cancellation to underlying Kotlin Coroutines. Firstly, another definition:

A task is a unit of work that can be run asynchronously as part of your program. All asynchronous code runs as part of some task.

It’s at the task level that cancellation occurs and to get notified when one is cancelled we can use withTaskCancellationHandler. But this is where the story ends for now. Given the current mechanism used to call Kotlin suspend functions from Swift we don’t have access for example to the associated job used (so we could cancel it as shown for example in following). Another possibility here is to not expose suspend functions from Kotlin shared code but to launch them separately and then cache/expose associated job (as described for example in another article here where we map a Kotlin Flow to a Combine Publisher)….seeing some issues here however from initial exploration of doing this (related to synchronisation with associated Swift async function). Will provide updates here as things develop!

await withTaskCancellationHandler(handler: {
    peopleJob?.cancel(cause: nil)
}) {
    await getPeopleAsync()
}

One other somewhat random observation was that if you mix approaches and pass say a success handler to your suspend function like following

suspend fun fetchPeople(success:  (List<Assignment>)  -> Unit) {
	val people = peopleInSpaceApi.fetchPeople().people
	success(people)
}

Then XCode offers a “consider using asynchronous alternative function” prompt and you can then invoke that method using something like:

async {
    do {
        print("before")
        try await repository.fetchPeople(success: { people in
            print(people)
            self.people = people
        })
        print("after")
    }
    catch {
        print("exception")
    }
}

This isn’t likely a common or useful approach but interesting to see nonetheless…I’m assuming this is based somehow on functionality that Swift is providing to allow use of await with “legacy” Objective-C etc functions that use completion handler.


Update June 13th 2021: Rick Clephas has also been doing similar explorations with changes added to this branch.

Using Rick’s approach I added following to class in iosMain

fun PeopleInSpaceRepository.fetchPeopleNative()
        = nativeSuspend { fetchPeople() }

And then updated getPeopleAsync as follows. Now if we say navigate to different view while this is in progress (assuming we’re calling it from task as above) then cancellation is propagated to shared Kotlin code and we see cancellation exception error printed out in the catch shown.

func getPeopleAsync() async  {
    do {
        self.people = try await asyncFunction(for: repository.fetchPeopleNative())
    }
    catch {
        print("Task error: \(error)")
    }
}


Featured in Kotlin Weekly Issue #256