Handling Kotlin Multiplatform Coroutines in Swift — Koru

avatar
Michał Klimczak
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 KotlinFoo.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 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 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 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, such as: LoadUserUseCaseIos, ObserveUsersUseCaseIos, SaveUserUseCaseIos. 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 CoroutineScopeSuspendWrapper 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 theiosMain 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 ...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.

@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 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?

Authors

avatar

Michał Klimczak

blog comments powered by Disqus
7 min. read
50%

More to discover

avatar
Tomasz Woźniak

Future Mind's Summary of 2020

The last quarter of each year tends to be highly intensive, but in 2020, Q4 exceeded our expectations. We began serious negotiations with numerous new Clients but also expanded some existing projects and made ambitious plans for their further development in 2021.
4 min. read
avatar
Agnieszka Twardosz

Customer Data Security in Retail

Retail businesses increasingly realize that they need customer data, but handling that data comes at a price – companies need to keep it safe and ensure customers’ privacy or risk a major crisis.
11 min. read

Is there anything we can do for you?

We use cookies to enhance your experience. Read more about cookies in our privacy policy. Agree