Bridging the Gap Between Kotlin Multiplatform and Swift with SKIE

Working with Kotlin Multiplatform and Swift? Explore SKIE and learn how this tool simplifies the process and empowers Swift developers.

Sky from an airplane
Photo by Ross Parmly on Unsplash

I’ve been working with Kotlin Multiplatform for quite a few years. While it wasn’t entirely smooth sailing, I strongly believe it is an excellent tool to share code.
Let’s quickly review some of its benefits:

  • Write your app’s logic just once: translates to less code, which usually means less bugs and better maintainability. There are some good articles about this topic, like this one
  • Native performance.
  • Ability to use all the native libraries made for Android and iOS.
  • We can write platform specific code if needed.

I have written an article comparing Kotlin Multiplatform to other cross-platform solutions here

Challenges for Swift / iOS Developers

While Kotlin Multiplatform offers benefits, iOS developers face some challenges compared to their Android counterparts. Let’s explore these challenges:

  • Non-exhaustive enums: In Kotlin, if you define an enum class, Swift always requires a default case, even if all possible enum values have dedicated cases.
  • Non-exhaustive sealed classes: Similar to enums, switching over sealed classes requires always adding a default case. Additionally it is hard to access to the associated values of Kotlin data classes
  • No support for default arguments: default arguments in Kotlin functions are not supported by Swift. You’ll always need to pass all arguments when calling such functions from Swift
  • No support for Kotlin exceptions: Kotlin exceptions aren’t handled by Swift. When a Kotlin function throws an exception, Swift will crash, and you cannot catch these errors from Swift code
  • Flow collection: as an iOS developer working with Kotlin Multiplatform, you have likely wondered how to observe a Kotlin Flow from Swift. Unfortunately, the solution isn’t straightforward.
  • Suspend functions: Kotlin suspend functions can only be called from the main thread. Additionally, cancellation is not supported
    Exception thrown after calling a suspend function from the main thread

So, even though both Kotlin and Swift are modern languages, the compatibility between them is not good. The main reason is that Kotlin code is translated to Objective-C, which lacks some of Kotlin’s features.

Introducing SKIE: Simplifying Kotlin Multiplatform

SKIE (pronounced as sky) is a special Kotlin native compiler plugin that brings back support for some of these features by modifying the Xcode Framework produced by the Kotlin compiler. Thanks to that, you don’t have to change how you distribute and consume your Kotlin Multiplatform frameworks.

Quote from: https://skie.touchlab.co/intro

So SKIE (developed by Touchlab) is a compiler plugin that generates Swift code by modifying the framework exported by the Kotlin compiler. There are two main SKIE features: those that create new types for easily dealing with Kotlin code, and those that empowers the reactive experience by making it a lot easier to observe Kotlin Flows and call suspend functions.

SKIE Features

  • Exhaustive enums: Swift can now handle exhaustive enums from Kotlin, eliminating the need for a default case.
  • Default arguments: Default arguments defined in Kotlin functions are now usable from Swift, eliminating the requirement to pass all arguments
  • Exhaustive sealed classes: Similarly to enums, now the Swift compiler can detect if we have implemented a case for each possible Kotlin data class or data object within the sealed class. Additionally, we can easily access to the associated values of Kotlin data classes.
    We just need to keep in mind that switch over sealed classes is a bit different:

    In this example, OperationType is a sealed class and we use onEnum(of:) to switch
    Also check how we can easily access to .unknown associated value
  • Flow interoperability: Every Kotlin Flow is represented as an AsyncSequence, which has some benefits:
    • Since a Kotlin Flow is just an AsyncSequence, we can use any third-party library like AsyncExtensions
    • Kotlin Flows participate in Swift’s async lifecycle. For instance, if we observe a Flow from a SwiftUI view’s task modifier, it will be automatically cancelled when the view is  removed
    • There is a special implementation for StateFlow that makes possible to get and set (if mutable) the underlying StateFlow’s value like this (messages is a StateFlow):
  • Suspend functions: SKIE allows us to call Kotlin suspend functions from any thread and manage cancellation at the same time!
    • Error handling: In order to support cancellation, suspend functions are translated to throwable async Swift functions. But If an exception other than CancellationException is thrown, it will lead to an error within Kotlin.
      However, by adding the @Throws annotation, any exceptions will surface on the Swift side, where they can be caught and handled:

How SKIE works

SKIE simplifies integration by generating Swift code that represents the underlying Kotlin implementations and classes. This generated code acts as a bridge between the two languages, providing a familiar Swift API for developers.

For instance, a new Swift enum is created to represent Kotlin enums and sealed classes. The Kotlin enum/sealed class is still there but hidden with underscores.

Regarding default arguments, SKIE might generate multiple Swift functions, each with a different number of parameters to accommodate the optional arguments. While this approach ensures compatibility, it’s important to note that it can increase the code size.

Plugin Configuration

SKIE offers two configuration options: global and local.

Global configuration

This approach involves setting the configuration within your Gradle file

skie {
    features {
        group {
            FlowInterop.Enabled(false)
        }
    }
}

In this case, the Flow interoperability feature is enabled by default in the entire project. However, SKIE allows you to selectively enable features in specific packages. This might be useful if you only need Flow support in certain parts of your codebase.

Local configuration

Provides more granular control over SKIE’s behavior within your codebase. You can achieve this using annotations directly within your Kotlin code. For instance, you can enable or disable Flow code generation on a per-function basis.

@FlowInterop.Enabled
fun enabledFlow(): Flow<Int> = flowOf(1)

@FlowInterop.Disabled
fun disabledFlow(): Flow<Int> = flowOf(1)

You can get the full list of gradle configuration options and annotations here

By default, SKIE enables all features except for default arguments. This is because generating multiple Swift functions to handle optional arguments can increase the binary size. You can explicitly enable default arguments if needed using local configuration.

Migrating existing projects

For existing projects, it is probably not a good idea to enable SKIE features by default. There is a very good chance that enabling SKIE on an existing codebase will lead to broken code. Let’s review the risk of enabling each one of SKIE’s features.

  1. Sealed Classes SKIE keeps the original sealed classes in the exported Objective-C header and generates parallel Swift enums, therefore it is safe to enable Sealed classes.
  2. Enums: SKIE generates a new Swift enum with the old Kotlin enum name, and “hides” the Kotlin enum prepending two underscores. According to SKIE, enabling enums by default can cause compilation issues.
  3. Default Arguments: This feature can considerably increase build times and binary size, by default this is disabled but shouldn’t cause compilation issues.
  4. Suspend functions: code using async/await for calling Kotlin suspend functions should work fine, but code passing in a completion block will fail to compile. And since SKIE suspend functions support cancellation we could get unexpected behaviors. This feature probably shouldn’t be enabled by default, unless we don’t have code calling suspend functions and passing completion blocks
  5. Flowif we have iOS code that directly uses Kotlin Flows, we are likely to encounter compilation problems. Additionally, SKIE advises  against using multiple Flow interop solutions. For example, if we’re using KMP-NativeCoroutines, we should disable this feature to avoid potential conflicts.

SKIE Caveats

  • Since SKIE represents Kotlin Flows as AsyncSequence in Swift, you cannot directly use Flow operators on these sequences
  • I had some issues with the gradle cache. After applying the SKIE plugin to a new project, I encountered a build failure. To solve this, I had to disable gradle cache.
  • SKIE can increase build time, especially during the linking step. It also increases the binary size. Thus, SKIE recommends limiting the number of exported classes/functions to manage this.

You can check the list of known issues and limitations here

Final thoughts

SKIE emerges as a valuable tool for bridging the gap between Swift and Kotlin Multiplatform development

This plugin is mentioned in Kotlin Multiplatform’s FAQ as one of the best tools to call suspend functions and observe Kotlin Flows from Swift.

While I have not used SKIE in a production application yet, the potential increase in build times and binary size is a valid concern. Fortunately, SKIE’s flexibility in feature selection allows developers to mitigate these concerns by enabling features only where neccesary.

Overall, SKIE represents a significant step towards a more unified development experience for both Swift and Android developers working with Kotlin Multiplatform.

You can check a code example here. It contains samples given by Touchlab and a ToDo list feature.