Writing Swift-Friendly Kotlin Multiplatform APIs — Part 1
Learn how to code libraries that your iOS teammates will not frown upon using. This is the first chapter in the series
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.
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:
- Objective-C doesn’t have namespaces, so symbols will be prefixed by the framework name (SharedSimplePerson ). Swift has a first level of namespacing, so the Shared prefix can be dropped. Nevertheless, we will see that prefixing still will be the way to address the lack of full namespacing
- The primitive types are converted to their Obj-C counterparts. Swift will convert them to Swift types
- The basic methods of Java Object class are renamed to follow Obj-C/Swift conventions
- We also see the infamous id, which is “a pointer to an instance of a class.” It is pretty much like an Object reference in Java in the sense that it can point to any object but not a primitive type like int. We will see that having ids in the interface when unexpected might signal a problem.
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 :
- Kotlin Multiplatform API
- Exported Objective-C header
- Swift example using the API
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 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 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])
And this is the end of Part 1. 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 the next chapter in the series that will tell you why your iOS devs are seeing underscores in the API symbols.
Writing Swift-friendly Kotlin Multiplatform APIs — Part II
Writing Swift-Friendly Kotlin Multiplatform APIs — Part 1 was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.