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.
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:
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:
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:
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:
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).
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.
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?
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
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 .
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?