Menu

Native and Transpiled Modes

Skip supports both native mode - in which your Swift is compiled natively for Android - and transpiled mode - in which your Swift is converted to Kotlin. The mode is specified at the level of a Swift module. Each mode has strengths and weaknesses, and it is common to use both native and transpiled modules within a single Swift-on-Android app.

Native Mode

Skip’s native mode compiles your Swift natively for Android. It is a combination of

  • A native Swift toolchain for Android.
  • Integration of Swift functionality like logging and networking with the Android operating system.
  • Bridging technology for using Kotlin/Java API from compiled Swift, and for using compiled Swift API from Kotlin/Java.
  • Xcode integration and tooling to compile and deploy your Swift across both iOS and Android.

The following diagram depicts a project that uses a native model layer and a transpiled UI layer:

Skip Native Diagram

Advantages

Skip’s documentation discusses its advantages over writing separate apps or using other cross-platform technologies. But why use native rather than transpiled Swift? Here are several important reasons you might prefer compiled Swift:

  • Full Swift language. While Skip’s intelligent Swift-to-Kotlin transpiler can translate the vast majority of the Swift language, there are some language features that cannot be mapped to Kotlin.
  • Faithful runtime behavior. Some aspects of Swift’s runtime behavior will never be the same when translated to Kotlin and run atop the JVM. For example, Swift’s deterministic object deallocation cannot be replicated on the JVM, which uses indeterministic garbage collection to manage memory.
  • Full stdlib and Foundation. The SkipLib and SkipFoundation libraries replicate much of the Swift standard library and Foundation in Kotlin. Native Swift on Android, however, has more complete coverage.
  • Third-party Swift libraries. Thousands of Swift packages compile for Android, and it often isn’t difficult to port others. This gives you access to a world of third-party packages without needing them to turn them into Skip modules.
  • Seamless C and C++ integration. While the SkipFFI framework enables transpiled Kotlin to interface with native C code on Android, the process of creating the interface between the two languages can be cumbersome. Native Swift on Android unlocks Swift’s excellent integration with the C and C++ languages.
  • High performance. Java on Android is very fast, but it is a garbage-collected and heap-allocated runtime. Swift’s value types like structs and enums, which can be stack-allocated, offer the fastest bare-metal performance possible on a device. And deterministic deallocations can keep memory watermarks low and avoid hitches that result from garbage collection pauses.

Disadvantages

Using native Swift doesn’t come without tradeoffs. Here are some notable disadvantages versus Skip’s transpilation:

  • App size. Bundling the Swift standard library, Foundation, and Swift internationalization libraries adds approximately 60 megabytes to your final Android app bundle size. We are exploring ways to reduce this overhead in future releases.
  • Kotlin/Java Bridging. Transpiled Swift can interact directly with Kotlin and Java API. Native Swift, on the other hand, must bridge back and forth between Swift and Java. Skip’s bridging technology is compelling, but it cannot rival transpiled Swift’s unbridged access to the JVM.
  • Debugging. Debugging native code on Android is more difficult than debugging generated Kotlin, where you can take full advantage of Android Studio’s built-in Kotlin/Java debugging tools.
  • Development Velocity. Building and deploying native Swift using the Android toolchain is slower than building using transpilation, due to overhead with the native compilation and packaging of the shared object files into the .apk.
  • Ejectability. One benefit of purely-transpiled code is that you can always “eject” from Skip and continue to iterate separately on your transpiled Kotlin code in Android Studio or another Kotlin IDE. Adding native Swift modules to your Android application complicates any attempts to eject from Skip in the future. Your iOS app would remain fully intact, but interfacing between your native Swift and the rest of your Android app is heavily dependent on the bridging code that is generated by the SkipStone plugin and would be arduous to write by hand.

Conclusion

We believe that native Swift’s advantages outweigh its disadvantages for most use cases, and that even these disadvantages will lessen over time as it matures. We expect, therefore, that most developers will choose to use native mode where possible. Transpiled mode, however, is excellent for cross-platform libraries that must interface intimately with Kotlin/Java API, so many apps will depend on transpiled libraries for using platform services.

Using SwiftUI in native mode is a work in progress. We recommend using Skip’s transpiled mode for your UI until native mode is complete, after which you can migrate to native if desired.

Transpiled Mode

Transpilation is the process of converting one computer language into another language with a similar level of abstraction. Skip’s transpiled mode converts your Swift source code into the equivalent Kotlin for running on Android. It is a combination of

  • A Swift-to-Kotlin transpiler.
  • A suite of open source libraries that mirror core frameworks like the Swift standard library and Foundation for transpiled Swift.
  • Xcode integration and tooling to transpile and deploy your Swift across both iOS and Android.

The following diagram depicts a pure transpiled project:

Skip Non-Native Diagram

Advantages

Transpiled mode’s advantages and disadvantages are mirror opposites of native mode’s.

  • Integration with Android APIs. The primary benefit of transpilation is near-perfect integration with Android’s Kotlin and Java APIs. Because your Swift code is converted to Kotlin, it can directly call other Kotlin API, just as if it were calling Swift. And because the Kotlin language features seamless integration with Java, your Swift code can call Java API as well. Unlike native mode, no bridging is required.
  • Transparency. Transpilation allows you to see and understand all of Skip’s output. Skip’s Kotlin is fully human-readable and even overridable: Skip includes the ability to insert or substitute literal Kotlin inline with your Swift. This is of particular use during debugging, where you get full stack traces and can take full advantage of Andriod’s debugging tools to step through your generated Kotlin.
  • Ejectability. If Skip were to disappear, transpiled mode still gives you the full source code to both the iOS and Android versions of your app. You could continue to evolve the app as separate iOS and Android codebases (which is how many dual-platform apps are developed). Skip does not have any required runtime components other than the libraries it uses to provide the Foundation, SwiftUI, etc APIs on Android, and these libraries are all free and open-source.
  • App size. Transpiled apps don’t have to bundle anything but Skip’s relatively slim compatibility libraries, while native apps have to include the much larger Swift Foundation and internationalization libraries.
  • Development velocity. The combination of Skip’s transpiler and the Android Kotlin compiler is faster than building with the full native Swift toolchain when iterating on your Android code.

Disadvantages

  • It isn’t real Swift. While Skip’s intelligent Swift-to-Kotlin transpiler can translate the vast majority of the Swift language, there are some language features that cannot be mapped to Kotlin. Additionally, some aspects of Swift’s runtime behavior will never be the same when translated to Kotlin and run atop the JVM. For example, Swift’s deterministic object deallocation cannot be replicated on the JVM, which uses indeterministic garbage collection to manage memory.
  • Limited stdlib and Foundation. The SkipLib and SkipFoundation libraries replicate much of the Swift standard library and Foundation in Kotlin. Native Swift on Android, however, has more complete coverage.
  • Few Third-party libraries. There are very few third-party transpiled libraries for Skip. Meanwhile, thousands of Swift packages compile for Android, and it often isn’t difficult to port others.
  • C and C++ integration. While the SkipFFI framework enables transpiled Kotlin to interface with native C code on Android, the process of creating the interface between the two languages can be cumbersome. Native Swift on Android unlocks Swift’s excellent integration with the C and C++ languages.
  • Performance. Java on Android is very fast, but it is a garbage-collected and heap-allocated runtime. It often has a high memory watermark. Native Swift’s value types like structs and enums, which can be stack-allocated, offer the fastest bare-metal performance possible on a device.

Conclusion

Transpiled mode’s ease of interacting with Kotlin and Java API makes it ideal for use cases involving tight integration with Android platform services, such as cross-platform libraries like Skip Keychain. For shared business logic and other general use cases, however, we believe that most developers will choose native mode - primarily for its complete language coverage and the availability of so many third-party packages. Many apps will use a combination of native app logic and transpiled libraries for accessing platform services.

Using SwiftUI in native mode is a work in progress. We recommend using Skip’s transpiled mode for your UI until native mode is complete, after which you can migrate to native if desired.

Configuration

Every Skip module must include a Skip/skip.yml file in its source directory. The skip init command creates this file for you automatically when you start a new project. The mode that you declare in skip.yml determines whether a module is compiled as native Swift or transpiled into Kotlin:

skip:
  mode: 'native'|'transpiled'

If the mode is not specified, it defaults to 'transpiled'. A project can contain a mix of native and transpiled modules. As we will see later, you also configure bridging between the native Swift and transpiled/Kotlin/Java in skip.yml.

The skip init command will also generate the appropriate dependencies for your target mode in Package.swift. For model-level code, a native module will typically depend on SkipFuse and SkipModel, while a transpiled module will depend on SkipModel alone. For UI code, a native module will depend on SkipFuseUI, while a transpiled module will depend on SkipUI. Here is an example Package.swift for an app that uses a transpiled SwiftUI interface over a native model layer:

import PackageDescription

let package = Package(
    name: "skipapp-hiya",
    defaultLocalization: "en",
    platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .macCatalyst(.v17)],
    products: [
        .library(name: "HiyaSkipApp", type: .dynamic, targets: ["HiyaSkip"]),
        .library(name: "HiyaSkipModel", type: .dynamic, targets: ["HiyaSkipModel"]),
        .library(name: "HiyaSkipLogic", type: .dynamic, targets: ["HiyaSkipLogic"]),
    ],
    dependencies: [
        .package(url: "https://source.skip.tools/skip.git", from: "1.2.7"),
        .package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0"),
        .package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"),
        .package(url: "https://source.skip.tools/skip-fuse.git", from: "1.0.0")
    ],
    targets: [
        .target(name: "HiyaSkip", dependencies: [
            "HiyaSkipModel",
            .product(name: "SkipUI", package: "skip-ui")
        ], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
        .testTarget(name: "HiyaSkipTests", dependencies: [
            "HiyaSkip",
            .product(name: "SkipTest", package: "skip")
        ], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
        .target(name: "HiyaSkipModel", dependencies: [
            "HiyaSkipLogic",
            .product(name: "SkipModel", package: "skip-model"),
            .product(name: "SkipFuse", package: "skip-fuse")
        ], plugins: [.plugin(name: "skipstone", package: "skip")]),
        .testTarget(name: "HiyaSkipModelTests", dependencies: [
            "HiyaSkipModel",
            .product(name: "SkipTest", package: "skip")
        ], plugins: [.plugin(name: "skipstone", package: "skip")]),
        .target(name: "HiyaSkipLogic", dependencies: []),
    ]
)

Bridging

Under normal circumstances, the only way for native code to communicate with Kotlin/Java is through the limited and cumbersome Java Native Interface, and the only way for Kotlin/Java to communicate with native code is through “native” or “external” functions.

Just as Xcode can generate “bridging headers” that allow your Swift and Objective C to interoperate, however, the SkipStone build plugin can generate code that enables transparent interaction between your native Swift and the Kotlin/Java world of Android services and UI. Bridging allows you to automatically project your compiled Swift types and API to Kotlin/Java, and to project your transpiled Swift as well as other Kotlin/Java types and API to native Swift. Bridged API can be used exactly as if it were written in the target language, just as Swift can use bridged Objective C and vice versa.

Configuration

You can use bridging with any Skip module - that is, any module that includes the SkipStone build plugin and has a skip.yml file. To bridge a type or API, add a // SKIP @bridge Skip comment to its declaration. The following example bridges the Person class and its name property, but its age property remains unbridged.

// SKIP @bridge
public class Person {
    // SKIP @bridge
    public var name: String
    public var age: Int

    ...
} 

Rather than annotate every bridged API, you can use skip.yml to enable auto-bridging. When auto-bridging is enabled for a module, all public API is bridged by default. To turn on auto-bridging, simply add bridging: true to your module’s skip.yml. No additional configuration is required.

For example, the following skip.yml configures a native Swift module whose public types and API will be bridged for consumption by Kotlin/Java code:

skip:
  mode: 'native'
  bridging: true 

To exclude a public type or API from being auto-bridged, add the // SKIP @nobridge comment. Here is the equivalent of our previous Person definition under auto-bridging:

public class Person {
    public var name: String
    // SKIP @nobridge
    public var age: Int

    ...
} 

Note that bridging: true is actually shorthand for the following full configuration syntax, which we’ll see more of below:

skip:
  mode: 'native'
  bridging:
    auto: true 

Bridging Swift to Kotlin/Java

Enabling bridging on a native Swift module allows its API to be used from Kotlin/Java code. The most common use case is writing your app’s business logic in native Swift, then enabling bridging so that your Android UI layer - whether it is written in pure Kotlin and Compose or in transpiled SwiftUI - can access it.

When Skip bridges native Swift to Kotlin, it generates Kotlin wrappers that delegate to your native Swift. By default, these wrappers are optimized for consumption by transpiled Swift, such as a transpiled SwiftUI interface. For example:

// In module NetworkUtils

public class URLManager {
    public var urls: [URL] = []

    public func perform(_ action: (URL) -> Void) {
        ...
    }

    ...
}

Generates a Kotlin wrapper like the following:

package network.utils

class URLManager {
    var urls: skip.lib.Array<skip.foundation.URL>
        get() {
            // Delegate to native code ...
        }
        set(newValue) {
            // Delegate to native code ...
        }

    fun perform(action: (skip.foundation.URL) -> Unit) {
        // Delegate to native code...
    }

    ...
}  

This allows any other Kotlin/Java code to interact with this API, but notice its use of skip.lib.Array and skip.foundation.URL. These are types from Skip’s suite of open-source libraries that mirror Swift API in Kotlin. Using them makes sense when your API will be consumed by transpiled Swift, because they are the same types Skip uses in its transpilation output. You get perfect interoperability, including expected Swift behavior like value semantics for the Array struct.

Kotlin Compatibility Option

If you plan on consuming your native Swift directly from Kotlin/Java instead - for example, a bespoke Compose UI for Android rather than transpiled SwiftUI - you can configure skip.yml to optimize for Kotlin compatibility:

skip:
  mode: 'native'
  bridging:
    auto: true
    options: 'kotlincompat'

Under this configuration, the generated Kotlin wrapper will use standard Kotlin/Java types:

package network.utils

class URLManager {
    var urls: kotlin.collections.List<java.net.URI>
        get() {
            // Delegate to native code ...
        }
        set(newValue) {
            // Delegate to native code ...
        }

    fun perform(action: (java.net.URI) -> Unit) {
        // Delegate to native code...
    }

    ...
} 

See the Bridging Support Reference for additional information on what standard types and Swift language constructs can be bridged.

Bridging Kotlin/Java to Swift

Skip offers multiple options for consuming Kotlin/Java API in native Swift. The most comprehensive is to bridge a transpiled Swift module.

Skip’s ability to transpile Swift to Kotlin is one of its strengths. Transpiled Swift code effectively is Kotlin, which gives it a superpower: it can directly call any other Kotlin/Java APIs, with no bridging or configuration, just as if they were written in Swift. The Skip documentation provides details.

When you enable bridging on a transpiled module and expose its API to native Swift, you are also exposing this superpower. How? Let’s consider an example. Suppose that your native Swift wants to store sensitive data in Android’s encrypted shared preferences, which are only accessible via Kotlin.

First, we create a transpiled module and enable bridging:

// Skip/skip.yml
skip:
  mode: 'transpiled'
  bridging: true

Then we write our Swift interface to the Android service, directly calling Android’s Kotlin API as needed. Recall that we can do this because Skip is transpiling our Swift into Kotlin for the Android build.

#if !SKIP_BRIDGE
#if os(Android)
import Foundation

import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys

/// Secure storage using Android encrypted shared preferences.
public final class SecureStorage {
    /// The shared keychain.
    public static let shared = SecureStorage()

    private let lock = NSLock()

    /// Retrieve a value.
    public func string(forKey key: String) -> String? {
        let prefs = initializePreferences()
        return prefs.getString(key, nil)
    }

    // Additional types omitted...

    /// Store a key value pair.
    public func set(_ string: String, forKey key: String) {
        let prefs = initializePreferences()
        let editor = prefs.edit()
        editor.putString(key, string)
        editor.apply()
    }

    // Additional types omitted...

    private var preferences: SharedPreferences?

    private func initializePreferences() -> SharedPreferences {
        lock.lock()
        defer { lock.unlock() }

        if let preferences {
            return preferences
        }

        let context = ProcessInfo.processInfo.androidContext
        let alias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
        preferences = EncryptedSharedPreferences.create("tools.skip.SecureStorage", alias, context, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
        return preferences!
    }
}
#endif
#endif

While this example is Android-only, we could instead have written a cross-platform secure storage implementation that you could use from both iOS (delegating to the Keychain) and Android (delegating to encrypted shared preferences). That is exactly what SkipKeychain does. Check out the cross-platform Swift implementation.

Finally, we add this module as a dependency of our native Swift module. Now our native Swift can import and use SecureStorage exactly as if it were just another Swift type!

When bridging is enabled on a transpiled module, enclose all code in #if !SKIP_BRIDGE to avoid duplicate symbol errors, as in our example above. The SkipStone build plugin will warn you if this is missing.

Example Bridging Diagram: SkipKeychain

The following visualization summarizes how the Skip generated bridging works in the context of the SkipKeychain framework, which exposes a direct Swift interface to the iOS-only Security Keychain services framework, and whose transpiled version uses the equivalent functionality in the androidx.security.crypto.EncryptedSharedPreferences package on Android.

Skip Bridging Diagram

AnyDynamicObject

We believe that creating an ecosystem of Swift-API modules like SkipKeychain to access both cross-platform and Android-only services is the best long-term approach, and that using transpiled Skip modules is a great pathway. Sometimes, however, you may want to use a few Kotlin/Java types from your native Swift without creating a secondary transpiled module. For this use case, we provide the AnyDynamicObject type.

AnyDynamicObject is a native Swift class vended by the SkipFuse framework that can represent any Kotlin/Java type. It uses Swift’s dynamic features to allow you to access any property or call any function, and it uses Kotlin/Java’s powerful reflection abilities to invoke your call on the underlying JVM object. The following is an example of using AnyDynamicObject in its bare form:

import SkipFuse

...

// Specify the Kotlin or Java class name and any constructor arguments
let date = try AnyDynamicObject(className: "java.util.Date", 999) // java.util.Date(999)

// All calls must specify their return type
// All function calls are `throws`
let time1: Int64 = try date.getTime() // 999 

// Like Kotlin, `AnyDynamicObject` allows property syntax for Java getters and setters
// Property access is not `throws`
let time2: Int64 = date.time // 999

// Java calls must use positional args. Kotlin calls may use labeled args and omit defaulted args
// Even `Void` function calls need to specify a return type 
try date.setTime(1000) as Void

// But property setters do not
date.time = 1001

// Retrieve other objects as `AnyDynamicObject`
let instant: AnyDynamicObject = date.instant
let s1: String = try instant.toString()

// Freely chain calls through other objects
let s2: String = try date.instant.toString()
let s3: String = try date.getInstant().toString() 

// Types with bridging support can be assigned from their Kotlin/Java equivalents
// See the Bridging Support Reference
let urls: [URL] = // ... call that returns any kotlin.collections.List<java.net.URI> 

This raw use of AnyDynamicObject has certain disadvantages, though. Constructing an object relies on always specifying the full class name, and there is no way to typealias it. Accessing static members is non-intuitive: it requires creating another AnyDynamicObject that uses a different constructor:

let dateStatics = try AnyDynamicObject(forStaticsOfClassName: "java.util.Date")
let date: AnyDynamicObject = dateStatics.parse(dateString)

Skip offers some optional syntactic sugar for these issues. To use it, set a dynamicroot string in your skip.yml configuration. The specified string turns into a magic namespace from which you can access fully-qualified Kotlin/Java types. For example, with the following skip.yml:

skip:
  mode: 'native'
  dynamicroot: 'D' 

You can write code like the following:

let date = try D.java.util.Date(999)
let time: Int64 = date.time  

How does this work? Skip’s build plugin detects your use of D.java.util.Date and generates a matching subclass of AnyDynamicObject. To access static members, the generated subclass includes a Companion field, mirroring Kotlin’s use of Companion for statics.

let date: AnyDynamicObject = try D.java.util.Date.Companion.parse(dateString) 

Because D.java.util.Date is a real type, you can create a typealias:

typealias JDate = D.java.util.Date

...

let d1 = JDate(999)
let s: String = try d1.instant.toString() 
let d2: JDate = JDate.Companion.parse(dateString)

If you have a reference to a base AnyDynamicObject value and you want to convert it to a generated dynamicroot type, use the as() function:

let object: AnyDynamicObject = ...
let date: D.java.util.Date = object.as(D.java.util.Date.self)

// or...

let date: JDate = object.as(JDate.self)

When using dynamicroot, keep the following in mind:

  1. Generated types have internal visibility, so they are only visible to your current module. This prevents conflicts when multiple linked modules use the same Kotlin/Java types.
  2. The types are only generated for the Android build of your code. After all, iOS can’t use Kotlin/Java API. So you must only use these generated types in code guarded with #if os(Android). See Platform Customization.

AnyDynamicObject is convenient, but remember that your calls are completely unchecked by the compiler. Attempting to use the wrong API will result in a runtime error. For a strongly-typed alternative, consider Apple’s swift-java project.

Bridging with AnyDynamicObject

The previous sections described two approaches to using Kotlin/Java from native Swift: bridging transpiled Swift code or using AnyDynamicObject. These approaches can also be combined.

Suppose that you’ve wrapped a Kotlin API in transpiled Swift for bridging. You may still want to expose the underlying Kotlin objects to your Android code for certain advanced use cases. Skip’s Firebase package mapping the iOS Firebase API to Android uses this pattern often. Here is a code excerpt:

public final class FirebaseApp {
    // Allow code to access underlying Kotlin object if needed
    public let app: com.google.firebase.FirebaseApp

    public init(app: com.google.firebase.FirebaseApp) {
        self.app = app
    }

    public var name: String {
        app.name
    }

    public var options: FirebaseOptions {
        FirebaseOptions(options: app.options)
    }

    ...
}

But how will the com.google.firebase.FirebaseApp type be represented to native Swift? This is a native Kotlin type, so Skip has no knowledge of its API. The answer is that Skip will map it to AnyDynamicObject. The bridged Swift implementation of FirebaseApp would look something like:

public final class FirebaseApp {
    public var app: AnyDynamicObject {
        // Delegate to Kotlin code ...
    }

    public init(app: AnyDynamicObject) {
        // Delegate to Kotlin code ...
    }

    public var name: String {
        // Delegate to Kotlin code ...
    }

    ...
}

You must specify an explicit type and use the fully-qualified Kotlin/Java class name on any Swift API that should map to AnyDynamicObject. The following transpiled Swift will correctly bridge to AnyDynamicObject:

let app: com.google.firebase.FirebaseApp = ...

func f(app: com.google.firebase.FirebaseApp) {
    ...
} 

But the following examples will produce build-time errors:

import com.google.firebase.FirebaseApp
 
let app: FirebaseApp = ... // Not fully qualified
let app = com.google.firebase.FirebaseApp() // No explicit type  

func f(app: FirebaseApp) { // Not fully qualified
    ...
} 

The combination of transpiled bridging and AnyDynamicObject allows you to provide a natural Swift API to important Kotlin and Java services while still exposing the raw Kotlin or Java objects for advanced use cases.

Migrating Between Modes

Whether you are using native or transpiled modes, you are writing your code in Swift. Migrating between them, therefore, is much easier than moving between different programming languages - in fact it is often trivial. This is particularly true when migrating from transpiled to native mode, given that transpiled mode supports only a subset of native mode’s Swift syntax, and native mode offers far more third-party libraries.

The first step in migration is to update your skip.yml and Package.swift files, as described in the Configuration section. Then it is a matter of migrating any code that behaves differently under each mode. See the chapters on Development and Platform Customization in particular.