DEV Community

Cover image for KMM: writing Kotlin API for Swift - 7 things you need to know
Evgeny Khokhlov
Evgeny Khokhlov

Posted on

KMM: writing Kotlin API for Swift - 7 things you need to know

This article is true for Kotlin 1.8

Kotlin Multiplatform Mobile (KMM) has been in beta for a while, first stable release is coming, API is safe to use and that's a perfect time to give it a try!

KMM has powerful bidirectional interoperability with Objective-C/Swift (kudos to Kotlin/Native), but it also has some limitations and tricky parts. It's not easy to write Kotlin API for your shared module that works nicely in Swift. I've tried to summarize all the things I've come across so far and learned the hard way. Where applicable I also provided links to documentation to help give you extra context.

Consider abstract class instead of interface in Kotlin API for Swift

Swift Protocols generated from Kotlin Interfaces don't have methods from kotlin.Any. When you are writing code in Kotlin you are often working with interfaces and implicitly using methods from Any. You can compare instances, print them or for example collect them in HashMap.

This code works pretty well in Kotlin:

interface Vehicle

class Garage {
    private var parkedVehicle: Vehicle? = null

    fun park(vehicle: Vehicle) {
        when (parkedVehicle) {
            null -> {
                parkedVehicle = vehicle
            }
            vehicle -> {
                println("$vehicle already parked")
            }
            else -> {
                println("garage is occupied")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

But in Swift part this interface Vehicle is translated to protocol Vehicle and can't be treated that way, it doesn't have any kind of equals or hashCode.

class SwiftGarage {
    private var parkedVehicle: Vehicle? = nil

    func park(vehicle: Vehicle) {
        if (parkedVehicle == vehicle) { // Compile error: 'Vehicle' cannot be used as a type conforming to protocol 'Equatable' because 'Equatable' has static requirements
            print("\(vehicle) already parked")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Of course you could cast Vehicle to NSObject.

class SwiftGarage {
    private var parkedVehicle: Vehicle? = nil

    func park(vehicle: Vehicle) {
        if (parkedVehicle == nil) {
            parkedVehicle = vehicle
        } else if ((parkedVehicle as! NSObject) == (vehicle as! NSObject)) {
            print("\(vehicle) parked")
        } else {
            print("garage is occupied")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It will work, but it doesn't look nice.

On the other hand Kotlin classes: regular and abstract maps methods of kotlin.Any (equals(), hashCode() and toString()) to the methods isEquals:, hash and description in Objective-C, and to the method isEquals(_:) and the properties hash, description in Swift. Also they conform Equatable, Hashable protocols in Swift.

In addition you can extend them to adopt and conform to any new protocol in Swift.

That's why it better to provide abstract class instead of interface where it's possible, when you are creating Kotlin API for shared module.

Don't rely on generics interface in your Kotlin API for Swift

Generics interoperability can only be defined on classes, not on interfaces (protocols in Objective-C and Swift) or methods. So ideally your Kotlin API shouldn't include generic interfaces.

Swift Generic generated from Kotlin Generics has limitations

Kotlin/Native has no direct Kotlin <-> Swift interoperability, it uses Kotlin <-> Objective-C and Objective-C <-> Swift interops to make it works. That's create some limitation.

For example Kotlin and Swift define nullability as part of the type specification, while Objective-C defines nullability on methods and properties.

That's why this class in Kotlin

class Container<T>(private val value: T) {
    fun get(): T = value
}
Enter fullscreen mode Exit fullscreen mode

transforms to

public class Container<T> : KotlinBase where T : AnyObject {
    public init(value: T?)
    open func get() -> T?
}
Enter fullscreen mode Exit fullscreen mode

in Swift.

Fortunately there is an easy trick to make our get function non-nullable. You need to provide non-null type constraint (e.g. kotlin.Any)

class Container<T: Any>(private val value: T) {
    fun get(): T = value
}
Enter fullscreen mode Exit fullscreen mode

now it will translate as expected.

Another interesting thing with generics is that in Swift part they have AnyObject constraint (maybe you've noticed it in previous example).

That means that we can't use Swift struct with them. It also means if we want to initialize our Container class with String or Int we got compiler error

let containerInt = Container(value: 1) // Generic class 'Container' requires that 'Int' be a class type 
let containerString = Container(value: "one") // Generic class 'Container' requires that 'String' be a class type 
Enter fullscreen mode Exit fullscreen mode

There is a solution for this too. You need to cast Swift values to Objective-C one's:

let containerInt = Container(value: 1 as NSNumber)
let containerString = Container(value: "one" as NSString)
Enter fullscreen mode Exit fullscreen mode

This code will compile just fine.

Avoid name collision even in different packages

All Kotlin classes and interfaces from shared module get into one Swift module. That's why having two (or more) classes or interfaces with the same name even if they are located in different packages can cause problems. Kotlin/Native deals with it by adding _ to the name in Swift module. For example if you have Utils class defined in 3 different packages in your shared module in Kotlin, you get Utils, Utils_, Utils__ in Swift.

It is not very obvious how to match these Swift classes with Kotlin's. That's why it's better to create unique names in Kotlin part.

Another option is to use experimental @ObjCName() annotation which tells Kotlin/Native how to translate names.

Include 3rd party libraries into shared module

When you are building your app in KMM, sometimes you need to pack some libraries along with project files in the shared module. For example if you are using Decompose you need to access it's Value class in Swift.

In order to do this, there is an export method in your Framework DSL in gradle build. If you are using regular framework setup you need to find binaries.framework block in your shared module build.gradle.kts. And if you are using cocoapods look at cocoapods.framework block. In this block you need to add export methods with all libraries you want to include.

it.binaries.framework {
    baseName = "shared"
    export("org.example:exported-library1:1.0")
    export("org.example:exported-library2:1.0")
}
Enter fullscreen mode Exit fullscreen mode

And also make sure that libraries decelerated in export are api dependencies. Export only works with api dependencies.

iosMain.dependencies {
    // ...
    api("org.example:exported-library1:1.0")
    api("org.example:exported-library2:1.0") 
}
Enter fullscreen mode Exit fullscreen mode

This very well explained in Kotlin documentation

Provide Kotlin API that doesn't make you invoke coroutine inside coroutine

The Kotlin/Native memory manager has a restriction on calling Kotlin suspending functions from Swift and Objective-C from threads other than the main one.

For example if you have Kotlin class

class Client {
    suspend fun getToken(): String
    suspend fun invokeSmth(token: String)
}
Enter fullscreen mode Exit fullscreen mode

And planning to use it in swift like this:

func handleButtonClick() {
    client.getToken { token, _ in
       client.invokeSmth(token: token) { _ in
           print("Invoked!")
       }
    }
}
Enter fullscreen mode Exit fullscreen mode

It won't work, app will crash while executing nested coroutine. To prevent this you could wrap calling of second coroutine in DispatchQueue.main.sync

func handleButtonClick() {
    client.getToken { token, _ in
       DispatchQueue.main.sync {
           client.invokeSmth(token: token) { _ in
               print("Invoked!")
           }
       }
    }
}
Enter fullscreen mode Exit fullscreen mode

Or else you could turn on experimental flag kotlin.native.binary.objcExportSuspendFunctionLaunchThreadRestriction=none in your gradle.properties. But it's better to avoid this situations by creating additional method in Kotlin part:

class Client {
    suspend fun getToken(): String
    suspend fun invokeSmth(token: String)
    suspend fun getTokenThenInvokeSmth(token: String) {
        val token = getToken()
        invokeSmth(token)
    }
}
Enter fullscreen mode Exit fullscreen mode

When you explore KMM articles, check article date before you read it

There are a lot of articles already has been written about Kotlin Multiplatform Mobile for the last 5 years. KMM api has been changed and improved since then. Inevitably some of very popular articles are outdated.

Good examples are articles about Kotlin/Native memory models. If there are advices about freezes and a lot of limitation, they are about old memory model which is deprecated now.

Latest comments (1)

Collapse
 
gilarc profile image
Gillar Prasatya

FYI, KMM is stable now

touchlab.co/kotlin-multiplatform-i....