Handling Kotlin Multiplatform coroutines in Swift — Koru
Suspend your apple with some codegen magic
Kotlin Multiplatform is taking the mobile world by storm. It went alpha a few months ago, more and more companies are embracing this technology, and all of that is not a coincidence. Compared to other multiplatform solutions, like Flutter or React Native, it has one very important advantage — you still write a native app (e.g. Swift on iOS), but use the KMM shared module just for the part that you want to share — usually your business logic.
The clue to KMM is the interoperability between Swift and Kotlin. In general, the Kotlin code from the shared module is compiled to a framework that translates Kotlin language elements to Obj-C / Swift compatible elements according to this mapping. There are some notable differences between the languages, but the basic conversions are very intuitive — all the classes, methods, and properties that you expose from your Kotlin module are accessible as classes, methods, and properties in Swift. So far, so good.
Accessing suspend functions from Swift
However, there is one thing that is very distinctive for Kotlin — concurrency can be handled at the language level. Kotlin enables suspend
modifiers for its functions. There is no counterpart in Obj-C or Swift, so Kotlin designers (in 1.4) decided to convert it to a completion handler.
class Foo {
suspend fun bar() : String = "bar"
}
Which can be used in Swift like this:
Foo().bar() { result, error in
//do something with bar result
}
The problem is that completion handlers are painful to work with, especially if you want to incorporate them in your Swift Combine or RxSwift observables. You would basically need to wrap this completion handler in a Future
for every single suspend function you expose — there is no way to make it generic. Also, there’s no way to handle cancellation, as the coroutine Job
is not exposed.
Russell Wolf from Touchlabs proposed a simple solution to this issue— wrapping all your suspend
functions and Flows
into wrappers, which can be easily converted to RxSwift Single
/Observable
on the Swift side. The problem with that solution is that you still need to wrap all your functions manually. The next step is to get rid of that boilerplate — and what sexier way to do it than codegen?
Lo and behold: https://github.com/FutureMind/koru
Basically, you add a@ToNativeClass
annotation to your class that contains suspend
or Flow
exposing functions, and you get a SuspendWrapper
or FlowWrapper
generated for you.
With a bit of magic, our KotlinFoo.bar()
becomes
let job = FooIos().bar().subscribe(
onSuccess: { barResult in print(barResult) },
onThrow: { error in print(error) }
)
Might not seem like much, but there are two significant differences:
- We get a
job
variable that lets us cancel the asynchronous work. - We have a consistent method signature — thanks to this, we can make a generic conversion to Swift Combine or RxSwift types.
But let’s see a complete example.
Example app
If you want to jump straight into the code, here it is: https://github.com/FutureMind/koru-example
Let’s create a very simple multiplatform app. We’re going to use the following setup:
shared
KMM module exposing anIosComponent
.iosApp
Swift application accessingIosComponent
viashared.framework
.androidApp
module accessing theshared
module via gradle.- coroutines for concurrency.
- Koin for dependency injection handling in Kotlin.
- Koru for wrapping suspend functions.
- Swift Combine for reactive programming in iOS.
Gradle setup
Important parts to notice:
kotlin(“kapt”)
plugin enables annotation processing.- Dependencies imports for
koru
andkoru-processor
(this one looks a bit intimidating, it’s because regularkapt
imports have a little bug). - Adding
generated/source/kaptKotlin
to sources dir — we need to tell the compiler where to look for generated Kotlin Native classes. It could be added tocommonMain
as well, but we only need those classes in iOS binary.
Setting up our business logic classes
Let’s assume we have a UserService
that loads some User
entities from the network. Our ViewModels in both iOS and Android code are interested in handling these users, hence we will expose simple use case classes for them to access:
So, we exposedsuspend
and Flow
functions. Our Android code can consume them directly, easy stuff. But what about iOS?
If you Make project right now, a few classes will be created for you in shared/build/generated/source/kaptKotlin
: LoadUserUseCaseIos
, ObserveUsersUseCaseIos
, SaveUserUseCaseIos
. Let’s take a look at one of them:
What happened? The generated class just wraps around the original one and calls its methods. There is, however, an important difference in return types:
suspend fun foo() : T
becomesfun foo() : SuspendWrapper<T>
fun foo() : Flow<T>
becomesfun foo() : FlowWrapper<T>
- and regular blocking functions are just called directly, nothing changes (
fun foo() : T
remainsfun foo() : T
).
ScopeProviders
You have probably noticed launchOnScope = MainScopeProvider::class
. What is that, you ask? Well, every coroutine has to be launched from CoroutineScope
. SuspendWrapper
and FlowWrapper
can launch their coroutines from any scope you provide, but handling it from inside the Swift code means that you need to deal with Kotlin implementation details in Swift. Your ViewModels should not have to deal with that stuff (nor your precious iOS colleagues).
Fortunately, you can provide them another way. First, you need to create the ScopeProvider
.
Now you can use it with @ToNativeClass(launchOnScope = MainScopeProvider::class)
, and it will be the default scope that launches your Swift coroutines (a default that you can still override if you need).
Wrapping it all together
Now we need to expose our generated classes to Swift code. We could do it by hand (val saveUserUseCaseIos = SaveUserUseCaseIos(SaveUserUseCase(UserService()))
) but of course it’s not maintainable for a large codebase and lots of dependencies. DI frameworks to the rescue — in this example we will use Koin 3 Alpha, which supports multiplatform projects.
Somewhere in commonMain
, we want to have a commonModule
accessed by both our platforms.
Android can load it directly with its startKoin
. For iOS, we need just one more step. Let’s create an IosComponent
file in theiosMain
module.
We have prepared an iosModule
that contains all our ...UseCaseIos
classes and injects the common use cases into them. We have exposed initIosDependencies()
, which should be called at the very startup of the iOS app for the dependency graph to get resolved. And finally, we have exposed our dependency container IosComponent
.
Now, we are ready to jump to Xcode.
Swift side
The first thing that we need to do in the iOS code is to resolve and access the IosComponent
we just created. In production code, you will probably use Swinject or something similar, but for the sake of brevity, let’s initialize it directly in AppDelegate
.
IosComponentKt.doInitIosDependencies()
calls our startKoin
, which resolves all the necessary dependencies (if the class and method names look confusing, take a look here). Now, we can create an instance of our IosComponent
and from there access all our business logic classes:
It’s all fine and dandy, but we wanted Swift Combine, right?
Swift Combine
Going from the callbacks to Combine is not hard — all we need is an extension
of SuspendWrapper<T>
, right? Unfortunately, extensions are not supported for Kotlin Native generic types because they are not supported for Obj-C generic types — and full interoperability between Kotlin Native and Swift is not there yet. It’s not a deal breaker, though, let’s just wrap it into a global function.
Now we can wrap our loadUserUseCase
and use it in a Combine chain.
How about matching some of our users into blind dates? Nothing easier.
You can find all the converter functions in the example repo.
Cancellation
You might have noticed that our SuspendWrapper.subscribe
returns Kotlinx_coroutines_coreJob
. This job is your entry point to coroutine cancellation. In our AnyPublisher
, we simply call job.cancel(cause: nil)
whenever the publisher is cancelled.
Nullability
Anything weird about nullability in those examples? I’m afraid so. Conversion from Kotlin to Obj-C drops the nullability info on the generic type SuspendWrapper<T>
. We know that our loadUserUseCase
returns a nullableUser?
, which means that we have to treat it as optional in Swift. However observeUserUseCase
is returning Flow<User>
, so it’s safe to force unwrap it, even though the Swift compiler is unaware.
What’s next
Full example
You can find the full code of this example here: https://github.com/FutureMind/koru-example
Apart from the working application, you can also find some abstract examples that show the capabilities of Koru. E.g. if you want to automagically create a LoadUserUseCaseIosProtocol
to create fakes for your unit tests — we have you covered with @ToNativeInterface
. Also be sure to check out the docs in README.
Future
In the current version, you still need to convert the SuspendWrapper to Combine inside your Swift code. What if we could just expose AnyPublisher
directly from Kotlin Native? That would be cool. Unfortunately it’s not possible at this time, as Kotlin Native is not 100% interoperable with Swift — just with C and Obj-C. Swift interoperability is, however, on the roadmap of Kotlin developers, maybe as soon as 1.5?