People Values
Tomek Jurek
Digital Advisory Customer Experience Technology
Izabela Franke
Digital Advisory CX Strategy Retail
Jakub Nawrocki
Digital Transformation Retail
Paweł Wasilewski
Values People
Tomek Jurek
Digital Advisory M-commerce
Izabela Franke

Featured Insights

Explore all insights
Close
People Values
Tomek Jurek
Digital Advisory Customer Experience Technology
Izabela Franke
Digital Advisory CX Strategy Retail
Jakub Nawrocki
Digital Transformation Retail
Paweł Wasilewski
Values People
Tomek Jurek
Digital Advisory M-commerce
Izabela Franke
Explore all insights

Featured Insights

EngineeringTechnology Mobile Development

Handling Kotlin Multiplatform Coroutines in Swift — Koru

Handling Kotlin Multiplatform Coroutines in Swift — Koru

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 SuspendWrapper or FlowWrapper generated for you.

With a bit of magic, our Kotlin Foo.bar() becomes

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 an IosComponent .
  • iosApp Swift application accessing IosComponent via shared.framework .
  • androidApp module accessing the shared 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

plugins { kotlin("multiplatform") id("com.android.library") kotlin("kapt") } kotlin { //... sourceSets { val coroutineVersion = "1.4.2-native-mt" val koruVersion = "0.3.2" val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion") { version { strictly(coroutineVersion) } } implementation("org.koin:koin-core:3.0.0-alpha-4") implementation("com.futuremind:koru:$koruVersion") configurations.get("kapt").dependencies.add( org.gradle.api.internal.artifacts.dependencies.DefaultExternalModuleDependency( "com.futuremind", "koru-processor", koruVersion ) ) } } val iosMain by getting { kotlin.srcDir("${buildDir.absolutePath}/generated/source/kaptKotlin/") } } } //...

Important parts to notice:

  • kotlin(“kapt”) plugin enables annotation processing.
  • Dependencies imports for koru and koru-processor (this one looks a bit intimidating, it’s because regular kapt 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 to commonMain 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:

@ToNativeClass(name = "LoadUserUseCaseIos", launchOnScope = MainScopeProvider::class) class LoadUserUseCase(private val service: UserService){ suspend fun loadUser(username: String) : User? = service.loadUser(username) fun getUserBlocking(username: String) : User? = service.getUser(username) } @ToNativeClass(name = "ObserveUsersUseCaseIos", launchOnScope = MainScopeProvider::class) class ObserveUsersUseCase(private val service: UserService){ fun observeUsers(usersCount: Int) : Flow<User> = service.observeUsers(usersCount) } @ToNativeClass(name = "SaveUserUseCaseIos", launchOnScope = MainScopeProvider::class) class SaveUserUseCase(private val service: UserService){ suspend fun saveUser(user: User) = service.sendUser(user) }

So, we exposed suspend 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 , such as: LoadUserUseCase Ios , ObserveUsersUseCase Ios , SaveUserUseCase Ios . Let’s take a look at one of them:

public class SaveUserUseCaseIos( private val wrapped: SaveUserUseCase ) { public fun saveUser(user: User): SuspendWrapper<Unit> = SuspendWrapper(exportedScopeProvider_mainScopeProvider) { wrapped.saveUser(user) } }

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 becomes fun foo() : SuspendWrapper<T>
  • fun foo() : Flow<T> becomes fun foo() : FlowWrapper<T>
  • and regular blocking functions are just called directly, nothing changes ( fun foo() : T remains fun 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 .

@ExportedScopeProvider class MainScopeProvider : ScopeProvider { override val scope : CoroutineScope = MainScope() }

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.

val commonModule = module { single { UserService() } factory { LoadUserUseCase(get()) } factory { SaveUserUseCase(get()) } factory { ObserveUsersUseCase(get()) } }

Android can load it directly with its startKoin . For iOS, we need just one more step. Let’s create an IosComponent file in the iosMain module.

fun initIosDependencies() = startKoin { modules(commonModule, iosModule) } private val iosModule = module { factory { LoadUserUseCaseIos(get()) } factory { SaveUserUseCaseIos(get()) } factory { ObserveUsersUseCaseIos(get()) } } class IosComponent : KoinComponent { fun provideLoadUserUseCase(): LoadUserUseCaseIos = get() fun provideSaveUserUseCase(): SaveUserUseCaseIos = get() fun provideObserveUserUseCase(): ObserveUsersUseCaseIos = get() }

We have prepared an iosModule that contains all our ...UseCase Ios 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 .

@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { let sharedComponent = IosComponent() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { IosComponentKt.doInitIosDependencies() return true } }

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:

let loadUserUseCase = sharedComponent.provideLoadUserUseCase() loadUserUseCase.loadUser(username: "nobody special").subscribe( onSuccess: { user in print(user?.description()) }, onThrow: { error in print(error.description()) } )

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.

func createPublisher<T>( wrapper: KoruSuspendWrapper<T> ) -> AnyPublisher<T?, Error> { var job: Kotlinx_coroutines_coreJob? = nil return Deferred { Future<T?, Error> { promise in job = wrapper.subscribe( onSuccess: { item in promise(.success(item)) }, onThrow: { error in promise(.failure(SharedError(error))) } ) }.handleEvents(receiveCancel: { job?.cancel(cause: nil) }) }.eraseToAnyPublisher() }

Now we can wrap our loadUserUseCase and use it in a Combine chain.

createPublisher(wrapper: loadUserUseCase.loadUser(username: "noone special")) .sink( receiveCompletion: { completion in print("Completion: \(completion)") }, receiveValue: { user in print("Hello from the Kotlin side \(user?.name)") } ) .store(in: &cancellables)

How about matching some of our users into blind dates? Nothing easier.

createPublisher(wrapper: observeUsersUseCase.observeUsers(usersCount: 5)) .zip(createPublisher(wrapper: observeUsersUseCase.observeUsers(usersCount: 5))) .sink( receiveCompletion: { _ in }, receiveValue: { user in let user1 = user.0!.name let user2 = user.1!.name print("Date between \(user1) and \(user2)") } ) .store(in: &cancellables)

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 nullable User? , 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?

Related insights

Arrange consultation with our Digital Advisory & Delivery Team

describe your challenge
Get in touch with our experts to learn more about the benefits of having us by your side.
We engineer
digital business

Subscribe to our insights

A better experience for your customers with Future Mind.

This field is required. Please fill it in, so we can stay in touch
This field is required.
© 2024 Future mind
all rights reserved