Writing Swift-friendly Kotlin Multiplatform APIs — Part VII: Coroutines

Learn how to code libraries that your iOS teammates will not frown upon using them. In this chapter: Coroutines

André Oriani
ProAndroidDev

--

An android juggling multiples apples concurrently — DALLE-2

In the beginning, the team led by Andy Rubin started with Java threads, Services, and Looper. But one had to write a lot of boilerplate code. Then we were advised to move to AsyncTask, but rotating the device would make that thing crash. But then they created a non-intuitive version of that: Loaders. When Jake Wharton began to play with RxJava, everyone followed suit. Then Jetbrains dug up a concept from 1959, and now we have Coroutines in Kotlin. That is the short history of concurrent programming in Android.

This article is part of a series, see the other articles here

Coroutines

Let us start with a simple example of two suspend functions. The idea is to use the result of fetchString as a parameter for calculateHash.

// KOTLIN API CODE
@OptIn(ExperimentalObjCName::class)
suspend fun fetchString(@ObjCName(swiftName = "_") param: Int): String {
println("Starting")
delay(100)
withContext(Dispatchers.IO) {
println("in some IO thread")
delay(1000)
}
println("back on original thread")
return param.toString()
}

@OptIn(ExperimentalObjCName::class)
suspend fun calculateHash(@ObjCName(swiftName = "_") param: String) = param.hashCode()
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
@interface SharedExampleKt : SharedBase
/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
+ (void)fetchStringParam:(int32_t)param completionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetchString(_:completionHandler:)")));
/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
+ (void)calculateHashParam:(NSString *)param completionHandler:(void (^)(SharedInt * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("calculateHash(_:completionHandler:)")));
@end
// HEADER "TRANSLATED" TO SWIFT 
public class ExampleKt : KotlinBase {
/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open class func fetchString(_ param: Int32, completionHandler: @escaping (String?, Error?) -> Void)

/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open class func fetchString(_ param: Int32) async throws -> String

/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open class func calculateHash(_ param: String, completionHandler: @escaping (KotlinInt?, Error?) -> Void)

/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open class func calculateHash(_ param: String) async throws -> KotlinInt
}

Surprisingly in Swift our suspend functions came in two flavors:

  • The Objective-C-matching completion handler flavor;
  • The asynchronous function flavor, which was introduced in Swift 5.5.

Completion Handler

// SWIFT CLIENT CODE
func usingCompletionHandler() {
ExampleKt.fetchString(1) {
stringResult, error in
if let error = error {
print("We had an error: \(error)")
} else {
ExampleKt.calculateHash(stringResult ?? "") {
intResult, error in
if let error = error {
print("We had an error: \(error)")
} else {
print("Final result \(intResult ?? 0)")
}
}
}
}
}

You may realized by now that completion handler is just Apple’s fancy name for callbacks. The disadvantages of using callbacks are clear:

  • It can easily create a Pyramid of doom or callback hell;
  • The coroutines cannot be canceled.

Tasks and async/await

// SWIFT CLIENT CODE
func usingTask() {
let task = Task { @MainActor in
do {
let stringResult = try await ExampleKt.fetchString(1)
let intResult = try await ExampleKt.calculateHash(stringResult)
print("Final result \(intResult)")
} catch {
print("We had an error: \(error)")
}
}
// This doesn't do what you think it'd do
task.cancel()
}

This looks cleaner, closer to how we would do in Kotlin. However task.cancel() does not work. The coroutine keeps running. The reason for that is straightforward. Note that the async functions only appear on the Objective-C translation to Swift. As we saw in Part V of this series, Swift bridging for Objective-C can substantially change the method’s signature, and that is also the case here. From Swift 5.5 and on, methods with completion handlers are translated to asynchronous functions. Because that process happens entirely on the Swift side, the task is completely unaware of the underlying coroutine, and therefore it cannot pass the cancellation request down to Kotlin.

Cancellation, dispatchers, coroutine context, task locals etc. are not propagated between Kotlin and Swift, they are discarded at the boundary between Kotlin and Swift.

You might be curious about that @MainActor annotation. Shall we remove it and see what happens?

2023-08-04 21:34:05.764759-0700 iosApp[3356:2506280] *** Terminating app due to uncaught exception 'NSGenericException', reason: 'Calling Kotlin suspend functions from Swift/Objective-C is currently supported only on main thread'
*** First throw call stack:
(
0 CoreFoundation 0x00007ff8004288ab __exceptionPreprocess + 242
1 libobjc.A.dylib 0x00007ff80004dba3 objc_exception_throw + 48
2 CoreFoundation 0x00007ff800428789 -[NSException initWithCoder:] + 0
3 shared 0x00000001025c7c40 Kotlin_ObjCExport_createContinuationArgument + 80
4 shared 0x00000001023eb1c9 objc2kotlin_kfun:io.aoriani.kmpapp#networkCall#suspend(kotlin.Int;kotlin.coroutines.Continuation<kotlin.String>){}kotlin.Any + 185
5 iosApp 0x00000001017dba60 $s6iosApp9usingTaskyyFyyYaYbcfU_TY0_ + 208
6 iosApp 0x00000001017ddf01 $sxIeghHr_xs5Error_pIegHrzo_s8SendableRzs5NeverORs_r0_lTRTQ0_ + 1
7 iosApp 0x00000001017de211 $sxIeghHr_xs5Error_pIegHrzo_s8SendableRzs5NeverORs_r0_lTRTATQ0_ + 1
8 libswift_Concurrency.dylib 0x00007ff835f7cfc1 _ZL23completeTaskWithClosurePN5swift12AsyncContextEPNS_10SwiftErrorE + 1
)
libc++abi: terminating with uncaught exception of type NSException
terminating with uncaught exception of type NSException
CoreSimulator 857.14 - Device: iPhone 14 Pro (CA3A7FC8-1DD6-4E44-BCE1-5A6D92CB1F3D) - Runtime: iOS 16.2 (20C52) - DeviceType: iPhone 14 Pro
*** Terminating app due to uncaught exception 'NSGenericException', reason: 'Calling Kotlin suspend functions from Swift/Objective-C is currently supported only on main thread'

The app crashes because suspend functions can only be started from iOS’ main thread and @MainActor tells Swift to run the task on the main dispatch queue. The reasons for that are stated in KT-51297:

Initially, calling Kotlin suspend functions on non-main thread from Swift was prohibited because it would be incompatible with particular approaches required by the old memory manager. Specifically, because of this kind of code: https://github.com/Kotlin/kotlinx.coroutines/blob/952ee683ee487438b5d01ab03b4780a4ab351719/kotlinx-coroutines-core/native/src/internal/Sharing.kt#L188
It dispatches a continuation to be resumed on the original thread.

The problem is: if the original thread doesn’t have a supported event loop, the task will never run, and the coroutine will never be resumed.

Additionally, IIRC, Apple doesn’t provide a way to dispatch a task to a particular (GCD-managed) background thread. So GCD-managed background threads just can’t have a “supported event loop” mentioned above.

As a consequence, if native-mt coroutines are used and Kotlin suspend function is called from Swift on a “regular” background thread, the coroutine that is started by this call will never resume after a suspension.
To avoid this, we prohibited calling Kotlin suspend function in Swift from non-main threads.

From Kotlin 1.7.20 and on you can remove the check for the main thread by adding the following to your gradle.properties file:

kotlin.native.binary.objcExportSuspendFunctionLaunchThreadRestriction=none

Just make sure you do not use the native-mt version of kotlinx-coroutines if you enable this.

A solution

Let me take a stab at the problem. As we saw in Part IV, we will have to write some Swift code to fill the gaps left by Objective-C. But for now, let’s concentrate on the Kotlin side. We need to wrap the suspend function with a type that provides a handle that allows us to:

  • Launch the suspend function on a predefined CoroutineScope;
  • Cancel the coroutine’s job.

That is what bellow SuspendWrapper is for. By the way, @HiddenFromObjC prevents the symbol from being exported to Objective-C. Note that I used @ObjCName to effectively replace the original suspend functions by their wrapped versions.

// KOTLIN API CODE
@OptIn(ExperimentalObjCRefinement::class)
@HiddenFromObjC
suspend fun fetchString(param: Int): String {
return try {
println("Starting")
delay(100)
withContext(Dispatchers.IO) {
println("in some IO thread")
delay(1000)
}
println("Back on original thread")
param.toString()
} catch (cancellation: CancellationException) {
println("I was cancelled")
throw cancellation
}
}

@OptIn(ExperimentalObjCRefinement::class)
@HiddenFromObjC
suspend fun calculateHash(param: String) = param.hashCode()

class SuspendWrapper<out T> internal constructor(
private val scope: CoroutineScope,
private val block: suspend () -> T & Any
) {
private var job: Job? = null
private var isCancelled = false

fun cancel() {
isCancelled = true
job?.cancel()
}

suspend fun run(): T & Any {
val deferred = scope.async(start = CoroutineStart.LAZY) { block() }
job = deferred
if (isCancelled) deferred.cancel() else deferred.start()
return deferred.await()
}
}

internal fun <T : Any> (suspend () -> T).wrap(scope: CoroutineScope = MainScope()): SuspendWrapper<T> {
return SuspendWrapper(scope = scope, block = this)
}

private val mainScope = MainScope()

@ObjCName("fetchString")
fun wrappedFetchString(param: Int) = suspend { fetchString(param) }.wrap(mainScope)

@ObjCName("calculateHash")
fun wrappedCalculateHash(param: String) = suspend { calculateHash(param) }.wrap(mainScope)
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("SuspendWrapper")))
@interface SharedSuspendWrapper<__covariant T> : SharedBase
- (void)cancel __attribute__((swift_name("cancel()")));

/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
- (void)runWithCompletionHandler:(void (^)(T _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("run(completionHandler:)")));
@end

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
@interface SharedExampleKt : SharedBase
+ (SharedSuspendWrapper<SharedInt *> *)calculateHashParam:(NSString *)param __attribute__((swift_name("calculateHash(param:)")));
+ (SharedSuspendWrapper<NSString *> *)fetchStringParam:(int32_t)param __attribute__((swift_name("fetchString(param:)")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class SuspendWrapper<T> : KotlinBase where T : AnyObject {
open func cancel()

/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open func run(completionHandler: @escaping (T?, Error?) -> Void)

/**
* @note This method converts instances of CancellationException to errors.
* Other uncaught Kotlin exceptions are fatal.
*/
open func run() async throws -> T
}

public class ExampleKt : KotlinBase {
open class func calculateHash(param: String) -> SuspendWrapper<KotlinInt>
open class func fetchString(param: Int32) -> SuspendWrapper<NSString>
}

On the Swift side, we need to be able to launch the task and be notified when the task is canceled. Asking my iOS teammates, Marquis Kurt pointed me to this nice function:

Given that, I just need to write a function that connects my Kotlin wrapper to that Swift function, and voilà.

// SWIFT CLIENT CODE
func suspend<T>(_ wrapper: SuspendWrapper<T>) async throws -> T {
return try await withTaskCancellationHandler {
@MainActor in try await wrapper.run()
} onCancel: {
wrapper.cancel()
}
}

func usingTask() {
let task = Task {
do {
let stringResult = try await suspend(ExampleKt.fetchString(param: 1))
let intResult = try await suspend(ExampleKt.calculateHash(param: stringResult as String))
print("Final result \(intResult)")
} catch {
print("We had an error: \(error)")
}
}
//It works now !
task.cancel()
}

Now canceling the task will also cancel the coroutine!

KMP-NativeCoroutines

Of course, I am not the only one to have this problem. Several libraries were created to address suspend functions:

// KOTLIN API CODE
@NativeCoroutineScope
internal val myCoroutineScope = MainScope()

@NativeCoroutines
suspend fun fetchString(param: Int): String {
println("Starting")
delay(100)
withContext(Dispatchers.IO) {
println("in some IO thread")
delay(1000)
}
println("back on original thread")
return param.toString()
}

@NativeCoroutines
suspend fun calculateHash(param: String) = param.hashCode()

The @NativeCouroutineScope annotation defines which scope to use. The @NativeCoroutines one does two things. First, it prevents the annotated functions from being exported to Objective-C. Second, after the annotation processing phase, it generates code for all iOS variants:

// ios* CODE GENERATED BY KSP

@ObjCName(name = "fetchString")
public fun fetchStringNative(`param`: Int): NativeSuspend<String> = nativeSuspend(myCoroutineScope)
{ fetchString(`param`) }

@ObjCName(name = "calculateHash")
public fun calculateHashNative(`param`: String): NativeSuspend<Int> =
nativeSuspend(myCoroutineScope) { calculateHash(`param`) }
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleNativeKt")))
@interface SharedExampleNativeKt : SharedBase
+ (SharedKotlinUnit *(^(^)(SharedKotlinUnit *(^)(SharedInt *, SharedKotlinUnit *), SharedKotlinUnit *(^)(NSError *, SharedKotlinUnit *), SharedKotlinUnit *(^)(NSError *, SharedKotlinUnit *)))(void))calculateHashParam:(NSString *)param __attribute__((swift_name("calculateHash(param:)")));
+ (SharedKotlinUnit *(^(^)(SharedKotlinUnit *(^)(NSString *, SharedKotlinUnit *), SharedKotlinUnit *(^)(NSError *, SharedKotlinUnit *), SharedKotlinUnit *(^)(NSError *, SharedKotlinUnit *)))(void))fetchStringParam:(int32_t)param __attribute__((swift_name("fetchString(param:)")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class ExampleNativeKt : KotlinBase {
open class func calculateHash(param: String) -> (@escaping (KotlinInt, KotlinUnit) -> KotlinUnit, @escaping (Error, KotlinUnit) -> KotlinUnit, @escaping (Error, KotlinUnit) -> KotlinUnit) -> () -> KotlinUnit
open class func fetchString(param: Int32) -> (@escaping (String, KotlinUnit) -> KotlinUnit, @escaping (Error, KotlinUnit) -> KotlinUnit, @escaping (Error, KotlinUnit) -> KotlinUnit) -> () -> KotlinUnit
}

Now on the Swift side, we use asyncFunction to launch the coroutine:

// SWIFT CLIENT CODE
func usingTask() {
let task = Task {
do {
let stringResult = try await asyncFunction(for: ExampleNativeKt.fetchString(param: 1))
let intResult = try await asyncFunction(for: ExampleNativeKt.calculateHash(param: stringResult))
print("Final result \(intResult)")
} catch {
print("We had an error: \(error)")
}
}
...
task.cancel()
}

This week we covered Coroutines. Next week, we will deal with Flows. Do not miss it! Meanwhile, take the time to read the other articles of the series:

Writing Swift-friendly Kotlin Multiplatform APIs

10 stories

References

--

--

Brazilian living in the Silicon Valley, Tech Lead and Principal Mobile Software Engineer @WalmartLabs