Writing Swift-Friendly Kotlin Multiplatform APIs — Part 1

André Oriani on 2023-07-02

Writing Swift-Friendly Kotlin Multiplatform APIs — Part I: Intro

Learn how to code libraries that your iOS teammates will not frown upon using them. This is the first chapter in the series

A photo-realistic illustration of a Google Android contemplating an apple — generated by Dalle-2

Kotlin Multi-Platform Mobile (KMM) is awesome… for Android developers. Using or coding a KMM library is not much different from using a regular Kotlin library like Jetpack Compose, OkHttp, and whatnot. However, for iOS developers, the story can be different. Therefore, it is essential to get them on board for the success of your KMM library.

This article is part of a series, see the other articles here

Although Swift has many similarities with Kotlin, it was built to replace Objective-C. As a result, it has many of its quirks, like the lack of proper namespacing. Consequently, a few Kotlin features will be like the Portuguese word saudade: they have no direct translation in Swift. Using them can produce useless or cumbersome APIs for your iOS teammates. This series of articles aims to help you identify those problems and provide solutions for the most common ones.

The Structure of the Examples

The examples in this series were built using the basic KMM project generated by the official Kotlin Multiplatform Mobile plugin for Android Studio. That project has a module name shared, where all the multiplatform code shall reside. The examples are going to start with some Kotlin code that looks like this:

// KOTLIN API CODE
class SimplePerson(val name: String, val age: Int) {
    override fun toString(): String = "($name : $age)"
    
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false
        other as SimplePerson
        if (name != other.name) return false
        return age == other.age
    }

    override fun hashCode(): Int {
        var result = name.hashCode()
        result = 31 * result + age
        return result
    }
}

Then they will be followed by how the header file of the framework exports the interface of that Kotlin code — that is what iOS libraries are called — generated by the KMM project. The header is located at shared/build/fat-framework/debug/shared.framework/Headers/shared.h.

// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("SimplePerson")))
@interface SharedSimplePerson : SharedBase
- (instancetype)initWithName:(NSString *)name age:(int32_t)age __attribute__((swift_name("init(name:age:)"))) __attribute__((objc_designated_initializer));
- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)")));
- (NSUInteger)hash __attribute__((swift_name("hash()")));
- (NSString *)description __attribute__((swift_name("description()")));
@property (readonly) int32_t age __attribute__((swift_name("age")));
@property (readonly) NSString *name __attribute__((swift_name("name")));
@end

The header is in Objective-C. KMM targets Objective-C because it provides wider support to iOS development and because the Swift ABI was not stable until quite recently. You need not know Objective-C, but reading the generated header can give you the first hints that something will go wrong even before you try to use the shared framework in XCode.

From the code above, we can already see a few things:

Finally, the example ends with some Swift code using the exported API:

// SWIFT CLIENT CODE
let person = SimplePerson(name: "John", age: 32)
if person != SimplePerson(name: "Anna", age: 27) {
    print("Not the same person: \(person.name)")       
}

In a nutshell, the format for each example will be :

Calling Methods Versus Sending Messages

Let’s start with a simple example:

// KOTLIN API CODE
fun findElementInList(elem: Int, list: List<Int>): Int = list.indexOf(elem)
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
@interface SharedExampleKt : SharedBase
+ (int32_t)findElementInListElem:(int32_t)elem list:(NSArray<SharedInt *> *)list __attribute__((swift_name("findElementInList(elem:list:)")));
@end
// SWIFT CLIENT CODE
let index = ExampleKt.findElementInList(elem: 1, list: [1, 2, 3])

No problems so far, but it is a little bit verbose. You have to call the function from the class that represents the source file (in this case Example.kt), like you would do in Java. But unlike Java, you cannot omit the parameter names, i.e., you cannot call it as

// SWIFT CLIENT CODE
ExampleKt.findElementInList(1, [1, 2, 3])

There is a historical reason for that. Kotlin was based on Java, which in its turn was based on C++, and therefore inherits the concept of calling methods on objects. On the other hand, Swift had to be compatible with Objective-C, which was based on Smalltalk. In Smalltalk, we send messages to objects. Sending messages or calling methods are similar in practice, but they impact the APIs’ design. For instance, an Objective-C developer would probably design the function above to be called like [ExampleKt findElement: 4 in: myList].

Note how much this reads like a “message” and how the parameter names play an essential role. You can see that in the Objective-C header, the first parameter elem was appended to the method name. Consequently, in Swift, the parameter names are part of the method signature and cannot be omitted. A Swift coder would more likely implement the above function as:

func find(element e: Int, in list: [Int]) -> Int {
    return list.index(of: e)
}

Note that Swift has external parameter names (element and in) that are part of the method signature and local parameter names (e and list) that are used internally in the implementation.

How can we yield a similar Swift idiomatic interface without compromising Kotlin? Kotlin 1.8 introduced the annotation @ObjCName that allows specifying the Objective-C and Swift names for symbols:

// KOTLIN API CODE
@OptIn(ExperimentalObjCName::class)
@ObjCName("find")
fun findElementInList(@ObjCName("element")elem: Int, @ObjCName("in") list: List<Int>): Int = list.indexOf(elem)
// EXPORTED OBJ-C HEADER
+ (int32_t)findElement:(int32_t)element in:(NSArray<SharedInt *> *)in __attribute__((swift_name("find(element:in:)")));
// SWIFT CLIENT CODE
let index = ExampleKt.find(element: 1, in: [1, 2, 3])

It is possible to improve it even more. In Swift, if the external parameter name is _ (an underscore), the parameter can be omitted when the function is called:

// KOTLIN API CODE
@OptIn(ExperimentalObjCName::class)
@ObjCName("find")
fun findElementInList(@ObjCName("_")elem: Int, @ObjCName("in") list: List<Int>): Int = list.indexOf(elem)
// EXPORTED OBJ-C HEADER
+ (int32_t)findElem:(int32_t)elem in:(NSArray<SharedInt *> *)in __attribute__((swift_name("find(_:in:)")));
// SWIFT CLIENT CODE
let index = ExampleKt.find(1, in: [1, 2, 3])

This is the end of Part I. We introduced the format to be used in the series. We also presented some key concepts useful in understanding the problems discussed in the next chapters. Be sure to check them:

Writing Swift-friendly Kotlin Multiplatform APIs Series of articles on how to write Kotlin Multiplatform libraries that work well with Swiftmedium.com