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 KotlinFoo.bar()
becomes
Might not seem like much, but there are two significant differences:
job
variable that lets us cancel the asynchronous work.But let’s see a complete example.
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.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.koru
and koru-processor
(this one looks a bit intimidating, it’s because regularkapt
imports have a little bug).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.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>
fun foo() : T
remains fun foo() : T
).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 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.
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 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.
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?