How to implement Swift-friendly API using Kotlin Multiplatform Mobile
Kotlin Multiplatform Mobile allows you to compile Kotlin code into native libraries for Android and iOS. If in the case of Android the library obtained from Kotlin will be integrated with an application written in Kotlin, then for iOS the integration will be with Swift. There is a loss of usability at the junction of Kotlin and Swift, due to the difference in languages. This is mainly because the Kotlin/Native compiler (which compiles Kotlin in the iOS framework and is part of the Kotlin Multiplatform) generates the public API of the framework in ObjectiveC. We access Kotlin from Swift through this generated ObjectiveC API, since Swift interacts with ObjectiveC. Further, I will show examples of API waste at the Kotlin-Swift junction and a tool that allows you to get a more convenient API for usage from Swift.
* * *
Let’s look at the example of using a sealed interface in Kotlin:
sealed interface UIState<out T> {
object Loading : UIState<Nothing>
object Empty : UIState<Nothing>
data class Data<T>(val value: T) : UIState<T>
data class Error(val throwable: Throwable) : UIState<Nothing>
}
This is a convenient construct to describe states, which is actively used in the Kotlin code. Let’s see how it looks from the Swift side.
public protocol UIState { }
public class UIStateLoading : KotlinBase, UIState { }
public class UIStateEmpty : KotlinBase, UIState { }
public class UIStateData<T> : KotlinBase, UIState where T : AnyObject {
open var value: T? { get }
}
public class UIStateError : KotlinBase, UIState {
open var throwable: KotlinThrowable { get }
}
From the Swift side Kotlin’s sealed interface looks like a set of classes with a common protocol. Of course, in this case, one cannot hope to check the completeness of the switch implementation, since it is not an enum. For developers familiar with Swift, the enum is considered a more correct analog of the sealed interface, for example:
enum UIState<T> {
case loading
case empty
case data(T)
case error(Error)
}
We can write such an enum from the Swift side and convert the resulting Kotlin UIState into our Swift enum, but what if there are many such sealed interfaces? The MVI approach is quite common in which the screen state and events are described exactly by the sealed class/interface. Writing an analogue in Swift for each such case is expensive. Moreover, we run the risk of class desynchronization in Kotlin and enum in Swift.
To solve this problem, we at IceRock made a special gradle plugin — MOKO KSwift. This is a gradle plugin that reads all the klibs used when compiling the iOS framework. Klib is the library format into which Kotlin/Native compiles everything before building the final binaries for a specific target. A lot of metadata is available inside klib, which gives complete information about the entire public Kotlin api, without loss of information. Our plugin analyzes all klibs that are specified in the export for the iOS framework (that is, those whose API will be included in the header of the framework), and, based on a complete understanding of the Kotlin code, it generates Swift code, in addition to the one Kotlin. For our UIState example, the plugin automatically generates the following construction:
public enum UIStateKs<T : AnyObject> {
case loading
case empty
case data(UIStateData<T>)
case error(UIStateError)
public init(_ obj: UIState) {
if obj is MultiPlatformLibrary.UIStateLoading {
self = .loading
} else if obj is MultiPlatformLibrary.UIStateEmpty {
self = .empty
} else if let obj = obj as? MultiPlatformLibrary.UIStateData<T> {
self = .data(obj)
} else if let obj = obj as? MultiPlatformLibrary.UIStateError {
self = .error(obj)
} else {
fatalError("UIStateKs not syncronized with UIState class")
}
}
}
We automatically get a swift enum that is guaranteed to match the sealed interface from Kotlin. This enum can be created by passing in the UIState object that we get from Kotlin. And this enum has access to classes from Kotlin to get all the information you need. Since this code is fully generated automatically at each compilation, we avoid the risks associated with the human factor — the machine cannot forget to update the code in Swift after changes occured in Kotlin.
* * *
Let's move on to the next example. In MOKO mvvm (our port of Android architecture components with Android in Kotlin Multiplatform Mobile) to bind LiveData to UI elements, we have implemented a set of extension functions for iOS, for example:
fun UILabel.bindText(
liveData: LiveData<String>
): Closeable
In use, instead of the convenient API label.bindText(myLiveData), UILabelBindingKt.bindText(label, myLiveData) is required.
This problem can also be solved by MOKO KSwift, since it has complete knowledge of the entire public interface of Kotlin libraries. As a result, the following function is generated:
public extension UIKit.UILabel {
public func bindText(liveData: LiveData<NSString>) -> Closeable {
return UILabelBindingKt.bindText(self, liveData: liveData)
}
}
* * *
Two generators are available currently out of the box in the KSwift plugin — SealedToSwiftEnumFeature (for generating Swift enum) and PlatformExtensionFunctionsFeature (for generating extensions to platform classes), however the plugin itself has an extensible API. You can implement the generation of the needed Swift code in addition to your Kotlin code without making changes directly to the plugin — only in your gradle project. By connecting the plugin as a dependency to buildSrc, you can write your own generator, for example:
import dev.icerock.moko.kswift.plugin.context.ClassContext
import dev.icerock.moko.kswift.plugin.feature.ProcessorContext
import dev.icerock.moko.kswift.plugin.feature.ProcessorFeature
import io.outfoxx.swiftpoet.DeclaredTypeName
import io.outfoxx.swiftpoet.ExtensionSpec
import io.outfoxx.swiftpoet.FileSpec
class MyKSwiftGenerator(filter: Filter<ClassContext>) : ProcessorFeature<ClassContext>(filter) {
override fun doProcess(featureContext: ClassContext, processorContext: ProcessorContext) {
val fileSpec: FileSpec.Builder = processorContext.fileSpecBuilder
val frameworkName: String = processorContext.framework.baseName
val classSimpleName = featureContext.clazz.name.substringAfterLast('/')
fileSpec.addExtension(
ExtensionSpec
.builder(
DeclaredTypeName.typeName("$frameworkName.$classSimpleName")
)
.build()
)
}
class Config(
var filter: Filter<ClassContext> = Filter.Exclude(emptySet())
)
companion object : Factory<ClassContext, MyKSwiftGenerator, Config> {
override fun create(block: Config.() -> Unit): MyKSwiftGenerator {
val config = Config().apply(block)
return MyKSwiftGenerator(config.filter)
}
}
}
In the above example, we include the analysis of Kotlin classes (ClassContext) and generate an extension in Swift for each of the Kotlin classes. In the Context classes, all the information from the klib metadata is available, and metadata contains all the information about classes, methods, packages, etc. To the same extent as compiler plugins, but read-only (while compiler plugins allow changing the code at compile time).
The plugin is a quite new solution for now and may not work correctly in some cases, which should definitely be reported in the issue at GitHub.
To preserve the ability to use the plugin and in cases where incorrect code is generated, the ability to filter the entities that used to generation has been added. For example, to exclude the class UIState from generation, you need to write in gradle:
kswift {
install(dev.icerock.moko.kswift.plugin.feature.SealedToSwiftEnumFeature) {
filter = excludeFilter("ClassContext/moko-kswift.sample:mpp-library-pods/com/icerockdev/library/UIState")
}
}
Filtering by processed libraries and the ability to enable the includeFilter mode (so that generation occurs only for specified entities) is also available.
If you are using Kotlin Multiplatform Mobile technology, I recommend that you try the plugin on your project (and give feedback on github) — the work of iOS developers will become better when they get a Swift-friendly API for working with the Kotlin module. And, if possible, share your generator options also on github — the more API improvements the plugin supports out of the box, the easier it will be for everyone.
Special thanks to Svyatoslav Scherbina from JetBrains for a tip about using klib metadata.