Learn how to code libraries that your iOS teammates will not frown upon using them. In this chapter: Coroutines
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.
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'
Job Offers
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:
- Koru by FutureMind — You can see how to use it in this talk given by Labellarte and Rotolo at Fosdem 2023, but it doesn’t seem to have been updated after Kotlin 1.8.0;
- SKIE by Touchlab — It requires an API key. I requested a trial one, but no answer from
so far;
- KMP-NativeCoroutines by Rick Clefas — It is simple to use and it is so being actively developed that when I reported an issue, Rick fixed it in a matter of couple days and pushed a new release. A deep dive into this library was made by John O’Reilly, but let us see how it could be applied to our code.
// 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.
References
- Native: allow calling Kotlin suspend functions on non-main thread from Swift — https://youtrack.jetbrains.com/issue/KT-51297/Native-allow-calling-Kotlin-suspend-functions-on-non-main-thread-from-Swift
- KMM New Memory Model Suspend Fun Main thread — https://youtrack.jetbrains.com/issue/KT-51166/KMM-New-Memory-Model-Suspend-Fun-Main-thread
- Kotlin docs — iOS Integration-Calling Kotlin suspending functions https://kotlinlang.org/docs/native-ios-integration.html#calling-kotlin-suspending-functions
- KLIMCZAK, Michał (
) — Handling Kotlin Multiplatform Coroutines in Swift — Koru https://www.futuremind.com/insights/handling-kotlin-multiplatform-coroutines-koru
- ROTOLO, Paolo (
) and LABELLARTE, Anna — Kotlin Multiplatform for Android & iOS library developers: Tips for writing Kotlin Multiplatform Android/iOS libraries https://fosdem.org/2023/schedule/event/kmp_for_android_and_ios_library_developers/
- Calling Objective-C APIs Asynchronously https://developer.apple.com/documentation/swift/calling-objective-c-apis-asynchronously
- O’REILLY, John — Bridging the gap between Swift 5.5 concurrency and Kotlin Coroutines with KMP-NativeCoroutines https://johnoreilly.dev/posts/kmp-native-coroutines/
This article was previously published on proandroiddev.com