Writing Swift-friendly Kotlin Multiplatform APIs — Part IV: Convenience

Learn how to code libraries that your iOS teammates will not frown upon using them. In this chapter: providing convenience.

André Oriani
ProAndroidDev

--

Android under an apple tree like Isaac Newton — DALL·E 2

In the previous chapter, we saw that using interfaces sometimes renders unexpected results. In this one, we will deal with situations in which the exported API is not too bad but could be improved substantially.

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

From this chapter on, there will be a small change in the format. If you read the extra article for this series, you know that is possible to “translate” the Objective-C header to Swift. So from now on, besides the Kotlin API and its correspondent Objective-C header, I will also provide the header’s translation to Swift, so we can have a better understanding of how the Kotlin code will be seen in Xcode when editing a Swift file.

Extensions

Let’s start with a simple Person interface and an extension to return the full name.

// KOTLIN API CODE
interface Person {
val firstName: String
val lastName: String
}

fun Person.fullName() = "$firstName $lastName"
// EXPORTED OBJ-C HEADER
__attribute__((swift_name("Person")))
@protocol SharedPerson
@required
@property (readonly) NSString *firstName __attribute__((swift_name("firstName")));
@property (readonly) NSString *lastName __attribute__((swift_name("lastName")));
@end

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
+ (NSString *)fullName:(id<SharedPerson>)receiver __attribute__((swift_name("fullName(_:)")));
@end
// HEADER "TRANSLATED" TO SWIFT
public protocol Person {
var firstName: String { get }
var lastName: String { get }
}

public class ExampleKt : KotlinBase {
open class func fullName(_ receiver: Person) -> String
}
// SWIFT CLIENT CODE
class SwiftPerson: Person {
let firstName: String
let lastName: String

init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
}
let swiftPerson = SwiftPerson(firstName: "Bruce", lastName: "Wayne")

// Java déjà vu?
print(ExampleKt.fullName(swiftPerson))

How to improve it

Well, it is not bad. It is pretty much like we would call the same extension from Java. But would not it be better if we could use the extension in Swift as we would do in Kotlin? If you read the previous chapter you may be thinking right now: “Make it a class!”. And yes, you are right.

// KOTLIN API CODE
abstract class Person(
val firstName: String,
val lastName: String
)

fun Person.fullName() = "$firstName $lastName"
// EXPORTED OBJ-C HEADER
__attribute__((swift_name("Person")))
@interface SharedPerson : SharedBase
- (instancetype)initWithFirstName:(NSString *)firstName lastName:(NSString *)lastName __attribute__((swift_name("init(firstName:lastName:)"))) __attribute__((objc_designated_initializer));
@property (readonly) NSString *firstName __attribute__((swift_name("firstName")));
@property (readonly) NSString *lastName __attribute__((swift_name("lastName")));
@end

@interface SharedPerson (Extensions)
- (NSString *)fullName __attribute__((swift_name("fullName()")));
@end
// HEADER "TRANSLATED" TO SWIFT
open class Person : KotlinBase {
public init(firstName: String, lastName: String)
open var firstName: String { get }
open var lastName: String { get }
}

extension Person {
open func fullName() -> String
}
// SWIFT CLIENT CODE
class SwiftPerson: Person {}
let swiftPerson = SwiftPerson(firstName: "Bruce", lastName: "Wayne")
print(swiftPerson.fullName())

The Kotlin extension is now exported as an Objective-C extension. In fact, if you read the documentation, there are a couple of other types, other than interfaces, for which Kotlin extensions will not be exported as Objective-C extensions:

  • Kotlin String type
  • Kotlin collection types and subtypes
  • Kotlin interface types
  • Kotlin primitive types
  • Kotlin inline classes
  • Kotlin Any type
  • Kotlin function types and subtypes
  • Objective-C classes and protocols, but MOKO KSwift may help you with these ones.

Refining APIs in Swift

Ranges and destructuring declarations are pretty handy features in Kotlin. They do also exist in Swift, but, because they do not exist in Objective-C, they are lost in translation as we see in the code below:

// KOTLIN API CODE
data class Point(val x: Int, val y: Int) {
fun isInRange(xRange: IntRange, yRange: IntRange): Boolean {
return x in xRange && y in yRange
}
}

// KOTLIN CLIENT CODE
fun test() {
val point = Point(x = 5, y = 7)
val (x,y) = point
point.isInRange(1..2, 3..4)
}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Point")))
@interface SharedPoint : SharedBase
- (instancetype)initWithX:(int32_t)x y:(int32_t)y __attribute__((swift_name("init(x:y:)"))) __attribute__((objc_designated_initializer));
- (SharedPoint *)doCopyX:(int32_t)x y:(int32_t)y __attribute__((swift_name("doCopy(x:y:)")));
- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)")));
- (NSUInteger)hash __attribute__((swift_name("hash()")));
- (BOOL)isInRangeXRange:(SharedKotlinIntRange *)xRange yRange:(SharedKotlinIntRange *)yRange __attribute__((swift_name("isInRange(xRange:yRange:)")));
- (NSString *)description __attribute__((swift_name("description()")));
@property (readonly) int32_t x __attribute__((swift_name("x")));
@property (readonly) int32_t y __attribute__((swift_name("y")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class Point : KotlinBase {
public init(x: Int32, y: Int32)
open func doCopy(x: Int32, y: Int32) -> Point
open func isEqual(_ other: Any?) -> Bool
open func hash() -> UInt
open func isInRange(xRange: KotlinIntRange, yRange: KotlinIntRange) -> Bool
open func description() -> String
open var x: Int32 { get }
open var y: Int32 { get }
}
// SWIFT CLIENT CODE
let point = Point(x: 5, y: 7)
let x = point.x
let y = point.y
let isInRange = point.isInRange(
xRange: KotlinIntRange(start: 0, endInclusive: 5),
yRange: KotlinIntRange(start: 2, endInclusive: 5)
)

How to improve it

Adding the @ShouldRefineInSwift to a function or property will cause the exported header for it to have the swift_private attribute. The symbol will then be hidden from Xcode auto-completion and its name will be prepended by two underscores (__ ) in Swift.

We can then define Swift extensions that will wrap the Kotlin API providing more convenient signatures. In this example, we provided an extension to convert the Point data class to a tuple, and to “rewrite” the method isInRange to accept Swift ranges, instead of forcing our iOS friends to cumbersomely create instances of IntRange.

Note that in the translation of the header from Objective-C to Swift, the isInRange method disappeared. The Swift extension had to be accessed as __is(inRangeXRange:yRange).

// KOTLIN API CODE
data class Point(val x: Int, val y: Int) {
@OptIn(ExperimentalObjCRefinement::class)
@ShouldRefineInSwift
fun isInRange(xRange: IntRange, yRange: IntRange): Boolean {
return x in xRange && y in yRange
}
}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Point")))
@interface SharedPoint : SharedBase
- (instancetype)initWithX:(int32_t)x y:(int32_t)y __attribute__((swift_name("init(x:y:)"))) __attribute__((objc_designated_initializer));
- (SharedPoint *)doCopyX:(int32_t)x y:(int32_t)y __attribute__((swift_name("doCopy(x:y:)")));
- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)")));
- (NSUInteger)hash __attribute__((swift_name("hash()")));
- (BOOL)isInRangeXRange:(SharedKotlinIntRange *)xRange yRange:(SharedKotlinIntRange *)yRange __attribute__((swift_private));
- (NSString *)description __attribute__((swift_name("description()")));
@property (readonly) int32_t x __attribute__((swift_name("x")));
@property (readonly) int32_t y __attribute__((swift_name("y")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class Point : KotlinBase {
public init(x: Int32, y: Int32)
open func doCopy(x: Int32, y: Int32) -> Point
open func isEqual(_ other: Any?) -> Bool
open func hash() -> UInt
open func description() -> String
open var x: Int32 { get }
open var y: Int32 { get }
}
// CONVENIENT SWIFT EXTENSION
extension Point {
var tuple: (Int32, Int32) { (x, y) }

func isInRange(xRange: ClosedRange<Int>, yRange: ClosedRange<Int>) -> Bool {
return __is(
inRangeXRange: KotlinIntRange(start: Int32(xRange.lowerBound), endInclusive: Int32(xRange.upperBound)),
yRange: KotlinIntRange(start: Int32(yRange.lowerBound), endInclusive: Int32(yRange.upperBound))
)
}
}

// SWIFT CLIENT CODE
let point = Point(x: 5, y: 7)
let (x, y) = point.tuple
let isInRange = point.isInRange(xRange: 0...5, yRange: 2...5)

Defining iOS-only extensions

You still can provide some convenience to iOS developers without touching Xcode. Besides defining actual implementation for the expect declarations, you can also take advantage of the fact that you have access to Apple’s Objective-C frameworks in iosMain. You can define functions and extensions that will only be available to iOS clients.

In the following example, we have a baseUrl defined in commonMain using Ktor’s Url . For Android, we defined an Url.toURL extension that returns java.net.URL. For iOS we defined the same extension but this time it returns NSURLfrom the Foundation framework. Conveniently NSURL is bridged to Swift’s URL. Note that it would be impossible to define such an extension in commonMain for two reasons:

  • They return platform-specific types which are not available in commonMain for obvious reasons;
  • You cannot overload methods to have the same signature but return different types.
// KOTLIN API CODE

// commonMain
import io.ktor.http.Url

object ServiceConfig {
val baseUrl = Url("https://api.server.com/service")
}

// androidMain
import io.ktor.http.Url
import java.net.URL

fun Url.toURL(): URL = URL(this.toString())

// iosMain
import io.ktor.http.Url
import platform.Foundation.NSURL

fun Url.toURL(): NSURL = NSURL(string = toString())

// Android Client code
val url = ServiceConfig.baseUrl.toURL()
val connection = url.openConnection()
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ServiceConfig")))
@interface SharedServiceConfig : SharedBase
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
+ (instancetype)serviceConfig __attribute__((swift_name("init()")));
@property (class, readonly, getter=shared) SharedServiceConfig *shared __attribute__((swift_name("shared")));
@property (readonly) SharedKtor_httpUrl *baseUrl __attribute__((swift_name("baseUrl")));
@end

@interface SharedKtor_httpUrl (Extensions)
- (NSURL *)toURL __attribute__((swift_name("toURL()")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class ServiceConfig : KotlinBase {
public convenience init()
open class var shared: ServiceConfig { get }
open var baseUrl: Ktor_httpUrl { get }
}

extension Ktor_httpUrl {
open func toURL() -> URL
}
// SWIFT CLIENT CODE
let url = Service.shared.baseUrl.toURL()
let session = URLSession(configuration: .default)
session.dataTask(with: url) {
...

Default Arguments

Default arguments were one of the nicest additions to Kotlin. We no longer need to worry about telescoping constructors, creating builders, etc… Sadly, they are not supported by Objective-C.

// KOTLIN API CODE
fun compareString(a: String, b: String, ignoreCase: Boolean = false): Boolean {
return a.equals(b, ignoreCase = ignoreCase)
}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
@interface SharedExampleKt : SharedBase
+ (BOOL)compareStringA:(NSString *)a b:(NSString *)b ignoreCase:(BOOL)ignoreCase __attribute__((swift_name("compareString(a:b:ignoreCase:)")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class ExampleKt : KotlinBase {
open class func compareString(a: String, b: String, ignoreCase: Bool) -> Bool
}
// SWIFT CLIENT CODE
let result1 = ExampleKt.compareString(a: "HELLO", b: "hello", ignoreCase: true)
// Compile error: Missing argument for parameter 'ignoreCase' in call
// let result2 = ExampleKt.compareString(a: "Roma", b: "Londres")

How to improve it

Unfortunately, there is no annotation for Kotlin Multiplatform that is the equivalent of @JvmOverloads yet. So you have to provide the overloads yourself

// KOTLIN API CODE
fun compareString(a: String, b: String, ignoreCase: Boolean): Boolean {
return a.equals(b, ignoreCase = ignoreCase)
}

fun compareString(a: String, b: String): Boolean {
return compareString(a, b, false)
}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
@interface SharedExampleKt : SharedBase
+ (BOOL)compareStringA:(NSString *)a b:(NSString *)b __attribute__((swift_name("compareString(a:b:)")));
+ (BOOL)compareStringA:(NSString *)a b:(NSString *)b ignoreCase:(BOOL)ignoreCase __attribute__((swift_name("compareString(a:b:ignoreCase:)")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class ExampleKt : KotlinBase {
open class func compareString(a: String, b: String) -> Bool
open class func compareString(a: String, b: String, ignoreCase: Bool) -> Bool
}
// SWIFT CLIENT CODE
let result1 = ExampleKt.compareString(a: "HELLO", b: "hello", ignoreCase: true)
let result2 = ExampleKt.compareString(a: "Roma", b: "Londres")

Alternatively, you can try SKIE from Touchlabs, which does provide compile-time support for default parameters.

Primitive? versus Primitive!

Objective-C does not have box types matching primitive types one-to-one like Java and Kotlin do. Objective-C does have NSNumber, NSInteger, and CGFloat, but they are not exactly type boxes. NSNumber, for instance, “doesn’t provide enough information about a wrapped primitive value type, i.e. NSNumber is statically not known to be Byte, Boolean, or Double”. Kotlin will use a primitive type when possible. However, when a box type is required, like in the case of a nullable return, Kotlin will use its own type box classes as we see below. Those types inherit from NSNumber. For function types, primitives will always be boxed. Curiously there is no KotlinChar as of Kotlin 1.9.0, so the character function below returns id _Nullable in Obj-C and Any? in Swift, id est, the type information was lost.

The solution is to either avoid nullables or use @ShouldRefineInSwift and wrap the function with a Swift extension to allow iOS devs to use more convenient types.

// KOTLIN API CODE
fun boolean(a: Boolean): Boolean? = a
fun integer(a: Int): Int? = a
fun character(a: Char): Char? = a
fun float(a: Float): Float? = a
fun double(a: Double): Double? = a
fun long(a: Long): Long? = a
fun short(a: Short): Short? = a
fun byte(a: Byte): Byte? = a
fun intFunc(a: (Int) -> Int?) {}
// EXPORTED OBJ-C HEADER
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ExampleKt")))
@interface SharedExampleKt : SharedBase
+ (SharedBoolean * _Nullable)booleanA:(BOOL)a __attribute__((swift_name("boolean(a:)")));
+ (SharedByte * _Nullable)byteA:(int8_t)a __attribute__((swift_name("byte(a:)")));
+ (id _Nullable)characterA:(unichar)a __attribute__((swift_name("character(a:)")));
+ (SharedDouble * _Nullable)doubleA:(double)a __attribute__((swift_name("double(a:)")));
+ (SharedFloat * _Nullable)floatA:(float)a __attribute__((swift_name("float(a:)")));
+ (void)intFuncA:(SharedInt * _Nullable (^)(SharedInt *))a __attribute__((swift_name("intFunc(a:)")));
+ (SharedInt * _Nullable)integerA:(int32_t)a __attribute__((swift_name("integer(a:)")));
+ (SharedLong * _Nullable)longA:(int64_t)a __attribute__((swift_name("long(a:)")));
+ (SharedShort * _Nullable)shortA:(int16_t)a __attribute__((swift_name("short(a:)")));
@end
// HEADER "TRANSLATED" TO SWIFT
public class ExampleKt : KotlinBase {
open class func boolean(a: Bool) -> KotlinBoolean?
open class func byte(a: Int8) -> KotlinByte?
open class func character(a: unichar) -> Any?
open class func double(a: Double) -> KotlinDouble?
open class func float(a: Float) -> KotlinFloat?
open class func intFunc(a: @escaping (KotlinInt) -> KotlinInt?)
open class func integer(a: Int32) -> KotlinInt?
open class func long(a: Int64) -> KotlinLong?
open class func short(a: Int16) -> KotlinShort?
}

In this chapter, we learned how to tenderize your APIs so they can be savored by your Xcode friends. Be sure to check the other articles in the series:

10 stories

References

--

--

Brazilian living in the Silicon Valley, Tech Lead and Principal Mobile Software Engineer @WalmartLabs