Menu

Native Swift Tech Preview

Introduction

Skip has long allowed developers to create cross-platform iOS and Android apps in Swift and SwiftUI by transpiling your Swift to Android’s native Kotlin language. Now, the Skip team is thrilled to give you the ability to use native, compiled Swift for cross-platform development as well. This technology preview is a combination of:

  • A native Swift toolchain for Android.
  • Integration of Swift functionality like logging and networking with the Android operating system.
  • Swift API for accessing various Android services.
  • Bridging technology for using Kotlin/Java API from Swift, and for using Swift API from Kotlin/Java.
  • The ability to power Jetpack Compose and shared SwiftUI user interfaces with native Swift @Observables.
  • Xcode integration and tooling to build and deploy across both iOS and Android.

Advantages

Skip’s documentation discusses its advantages over writing separate apps or using other cross-platform technologies. But why use compiled rather than transpiled Swift? Here are several important reasons you might prefer native 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. Many Swift utility libraries compile cleanly for Android right out of the box, giving you access to a world of third-party packages without needing them to be turned 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 will be 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.

The Big Picture

How does native Swift fit into a typical cross-platform Skip app? Android app startup, app UI, and almost all platform services are only exposed through Kotlin/Java. Additionally, Skip’s SwiftUI support is currently limited to transpiled Swift modules. Your native Swift modules cannot define SwiftUI views.

The sweet spot for native Swift, therefore, is shared utility and business logic modules. Your native Swift modules will typically depend on transpiled modules to communicate with lower-level Android services, and will in turn be depended upon by your Jetpack Compose or transpiled SwiftUI layer to perform business logic and define view models. This is the setup the skip init --native command creates by default, as outlined in Getting Started.

Hybrid Native/Transpiled Development Diagram

The following diagram illustrates development of a cross-platform app using a native Swift model layer to power a transpiled SwiftUI interface.

Skip Native Diagram

Pure Transpiled Development Diagram

Contrast the hybrid native development diagram above with the following illustration of a pure-transpiled Skip app:

Skip Non-Native Diagram


Getting Started

Follow along with the Skip documentation’s Getting Started chapter to install Skip. If you already have Skip installed, run skip upgrade to get the latest version (1.2.0 or higher). Skip’s native support requires Swift 6, so make sure you have Xcode 16.1 or later.

Once you have installed Skip, you will also need to install the Swift Android toolchain with the command skip android sdk install. If this command is successful, running swift sdk list will list the local SDKs, which should include an entry like: swift-6.0.2-RELEASE-android-24-0.1.

To verify that the toolchain is setup properly and able to build a test app with native Swift integration, run the command skip checkup --native, whose output should end with:

[âś“] Archive iOS ipa (28.01s)
[âś“] Assemble HelloSkip-release.ipa 405 KB
[âś“] Verify HelloSkip-release.ipa 405 KB
[âś“] Assembling Android apk (133.23s)
[âś“] Verify HelloSkip-release.apk 212 MB
[âś“] Check Swift Package (0.97s)
[âś“] Check Skip Updates: 1.1.24
[âś“] Skip 1.1.24 checkup succeeded in 314.86s

If any steps in the checkup command fail, consult the generated log file, which should contain an error message describing the failure. You can seek assistance on our Slack or discussion forums.

Creating a Cross-Platform SwiftUI App

Once you’re up and running, use the new --native option to create a cross-platform SwiftUI app that uses a native Swift model layer. Here is the command to create a HelloSwift app in a hello-swift project folder:

skip init --native --open-xcode --appid=com.xyz.HelloSwift hello-swift HelloSwift HelloSwiftModel

Your appid must contain at least two words, and each word must be separated by a .. It is conventional to use reverse-DNS naming, such as com.companyname.AppName. Also make sure that your project-name and AppName are different. It is conventional to use a lowercase, hyphenated name for your project (which Skip uses to create your app’s main SwiftPM package name), and UpperCamelCase for your app name.

skip init creates a functional template app, but before you can build and launch it, an Android emulator needs to be running. Launch Android Studio.app and open the Virtual Device Manager from the ellipsis menu of the Welcome dialog. From there, Create Device (e.g., “Pixel 6”) and then Launch the emulator.

Screenshot of the Android Studio Device Manager

Once the Android emulator is running, select and run the HelloSwift target in Xcode. The first build will take some time to compile the Skip libraries, and you may be prompted with a dialog to affirm that you trust the Skip plugin. Once the build and run action completes, the SwiftUI app will open in the selected iOS simulator, and at the same time the generated Android app will launch in the currently-running Android emulator.

Screenshot of the Hello Swift native app

Anatomy of a Hybrid Native/Transpiled App

A pared-down version of the created sample app looks like this:

hello-swift
├── Package.swift
└── Sources
    ├── HelloSwift
    │   ├── ContentView.swift
    │   └── Skip
    │       └── skip.yml
    └── HelloSwiftModel
        ├── Skip
        │   └── skip.yml
        └── ViewModel.swift

You can also browse this sample package online in the skipapp-hiya repository.

This package contains two separate modules. The app module HelloSwift contains the SwiftUI that will be transpiled into Kotlin to handle the user-interface layer of the application. The HelloSwiftModel is the native Swift module that contains an @Observable ViewModel which, along with any dependencies that it declares, will be compiled natively for Android. The SkipStone plugin will automatically create a bridge from the native Swift to the transpiled Kotlin in the HelloSwift module. This division is further discussed in Powering Your UI.

Creating Separate iOS and Android Apps

Rather than create a single cross-platform app, you might prefer to maintain separate, bespoke apps for iOS and Android. These apps can still share a native Swift model layer or other native Swift libraries. As we discuss in Powering your UI, Skip’s native @Observable support for Android works for pure Compose UIs as well as transpiled SwiftUI.

The TravelPostersNative sample implements this pattern. It contains both an Xcode app and an Android Studio app that share a travel-posters-model native Swift model. This model is a standard Swift Package Manager package created with skip init, as described in Skip’s documentation. The module’s skip.yml configuration declares it to be native.

We discuss skip.yml in the next section. If you prefer the dual-app approach, use the TravelPostersNative sample as your guide.

skip.yml

Every Skip module must include a Skip/skip.yml file in its source directory. The skip init command creates this file for you automatically. 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'. In the 2-module app created with skip init --native …, the HelloSwift module’s mode is 'transpiled', and the dependent HelloSwiftModel module’s mode is 'native'.

As we will see in a later section, you also configure bridging in skip.yml.


Development

In general, dual-platform Skip app development is modern iOS app development. You work in Xcode. You code in Swift and - if you choose to share UI code - in SwiftUI. As much as possible, Skip’s goal is to disappear. This section focuses on topics to be aware of when writing native Swift that will run cross-platform on Android as well.

Most apps contain both compiled and transpiled modules. Read the Development documentation chapter for information on developing with transpiled Swift.

Differences

Skip’s native Swift is truly Swift - you have access to the full Swift 6 language, compiled by Apple’s Swift compiler. The Swift standard library is also available. So what is different?

  • Parts of Foundation are not yet implemented for Android, including UserDefaults, resource loading through Bundle.module, and String localization functions.
  • Other parts of Foundation have been moved into separate libraries: FoundationNetworking, FoundationInternatinalization, and FoundationXML. Use the following pattern to import these libraries in cross-platform code:

      #if canImport(FoundationNetworking)
      import FoundationNetworking 
      #endif
    
  • Most other built-in iOS frameworks, including Combine and Apple’s various “Kits”, are not available.
  • While some utility and third-party frameworks do work out of the box on Android (e.g. swift-algorithms), any library that itself relies on missing frameworks will not compile.
  • Swift is not yet aware of your Android app’s run loop, so @MainActor, Dispatchers.main, and other API that relies on the main run loop does not work (but other Dispatch and async functions do work).

The Swift community is constantly improving Swift support across many platforms including Android, so coverage of Foundation and other frameworks will gradually increase. The SkipFuse framework described below also fills some of the gaps in functionality.

Note that most of the missing functionality named above - UserDefaults, Bundle.module, localization, and the main run loop - is available in Skip’s transpiled Swift modules. We are working on making it available to native Swift.

Development Process

If you are developing a cross-platform app created with skip init --native, Skip will automatically build and run the Android version of your app alongside each iOS build. This allows you to see your iOS and Android versions side by side and catch any cross-platform issues early.

Always make sure to have a running Android emulator or connected Android device during development.

If you are developing a standalone package or separate iOS and Android apps, however, this is not the case. Due to limitations on Xcode plugins, Skip only performs an Android build of a non-app target when you run your unit tests. Moreover, you must run your tests against the Mac rather than against an iOS simulator so that Skip can leverage Robolectric, a simulated Android environment on the Mac.

The skip export command you use to deploy apps and packages also performs a full Android build.

SkipFuse

SkipFuse helps fuse the Swift and Android worlds. It is an umbrella framework that vends cross-platform functionality. For example, OSLog isn’t available in Swift for Android. But when you import SkipFuse, you can use the OSLog API across platforms. SkipFuse uses that native OSLog on iOS, and it outputs to Android’s Logcat logging service on Android. The documentation sections on Debugging, Bridging, and Powering Your UI introduce other SkipFuse services.

We will be enhancing SkipFuse over time to integrate many additional Swift and Foundation APIs with Android.


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 enable bridging on any Skip module - that is, any module that includes the SkipStone build plugin and has a skip.yml file. When bridging is enabled for a module, all public API is bridged by default. To turn on 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 bridged, add the // SKIP @nobridge Skip comment to its declaration. For example:

// SKIP @nobridge
public var i = 0

// SKIP @nobridge
public class C {
    ...
}  

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 shared SwiftUI that Skip transpiles - can access it. We explore this use case more deeply in Powering Your UI.

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 shared SwiftUI. 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 Skip’s shared SwiftUI - you can configure skip.yml to optimize for Kotlin compatibility:

skip:
  mode: 'native'
  bridging:
    enabled: 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.

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 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 below
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  

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)

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 Support Reference

This section details the Swift language features and types that can be bridged. Bridging capabilities are symmetrical unless otherwise noted. That is, if something is marked as bridgeable, then you can use it whether you are bridging from native Swift to Kotlin/Java, or from transpiled Kotlin/Java to native Swift.

Bridging support will expand in future releases. Most current limitations are due to lack of time and testing rather than technical hurdles.

  • âś“ Classes
    • âś• Inheritance
  • âś“ Structs - see the Mutable Structs topic below
    • âś“ Constructor synthesis
    • âś“ Equatable synthesis
    • âś“ Hashable synthesis
  • âś“ Protocols
    • âś“ Inheritance
    • âś“ Property requirements
    • âś“ Function requirements
    • âś• Constructor requirements
    • âś• Static requirements
  • ~ Enums
    • âś“ Enums without associated values
    • âś• Enums with associated values
  • âś“ Nested types
  • âś• Extensions
  • âś• Generic types
  • âś“ Tuples - up to 5 elements
    • âś• Not supported as collection elements or closure parameters
  • âś• Typealiases
  • âś“ Properties
    • âś“ Globals
    • âś“ Members
    • âś“ let
    • âś“ var
    • âś“ Static properties
    • âś“ Stored properties
    • âś“ Computed properties
    • âś“ Throwing properties
    • âś• Lazy properties
  • âś“ Functions
    • âś“ Globals
    • âś“ Members
    • âś“ Overloading on types
    • âś“ Overloading on param labels
    • âś• Overloading on return type
    • âś“ Static functions
    • âś• Generic functions
    • âś“ Throwing functions
    • âś“ Default parameter values
    • âś• inout parameters
    • âś• Variadic parameters
    • âś• @autoclosure parameters
    • âś• Parameter packs
  • âś“ Constructors
    • âś• Optional constructors
  • âś“ Deconstructors
  • âś“ Closures - up to 5 parameters
    • âś• Not supported as collection elements or parameters to other closures
  • âś“ Errors - see the Errors topic below
  • ~ Concurrency
    • âś“ Async functions
    • âś• Async properties
    • âś• Async closures
    • âś• @MainActor (Native Swift does not yet integrate with the Android run loop)
    • âś• Custom actors
  • ~ Operators
    • âś“ Custom Equatable with ==
    • âś“ Custom Hashable with hash(into:)
    • âś“ Custom Comparable with <
    • âś• Custom subscript operators
    • âś• callAsFunction support
    • âś• Other custom operators
  • âś• Key paths

  • âś“ Numeric types
    • âś“ Uses Kotlin native types
    • ~ Int is 32 bit on JVM
  • âś“ String
    • âś“ Uses Kotlin native String
  • âś“ Bool
  • âś• Character
  • âś• Any, AnyObject
  • âś“ Optionals
  • âś• Compound types (e.g. A & B)
  • âś“ Array - translates to kotlin.collections.List in kotlincompat mode
  • âś“ Data - translates to [byte] in kotlincompat mode
  • âś“ Date - translates to java.util.Date in kotlincompat mode
  • âś“ Dictionary - translates to kotlin.collections.Map in kotlincompat mode
  • âś• Error
  • âś• OptionSet
  • âś“ Set - translates to kotlin.collections.Set in kotlincompat mode
  • âś“ 2-Tuple - translates to kotlin.Pair in kotlincompat mode
  • âś“ 3-Tuple - translates to kotlin.Triple in kotlincompat mode
  • âś“ URL - translates to java.net.URI in kotlincompat mode
  • âś“ UUID - translates to java.util.UUID in kotlincompat mode

Equality

Do not rely on object identity and === comparisons of bridged instances. The same object may get wrapped by multiple bridging instances. Because Kotlin/Java types have built-in equals and hashCode functions that default to using identity, Skip’s Kotlin projections of your native types will implement equals and hashCode so that wrappers around the same native instance will compare equal and have the same hash.

Skip supports bridging of Equatable, Hashable, and Comparable types, so you should implement these protocols for any additional needs.

Errors

Skip supports bridging functions that may throw errors. Skip does not, however, supporting bridging Error types, and therefore you must treat throwing functions as if they might throw any error at all. Use general catch blocks that do not rely on catching specific types of errors.

Mutable Structs

We only recommend bridging native mutable structs if their projection will be consumed by transpiled Swift, where Skip can maintain value semantics on the Kotlin side. If you plan on using your native types from pure Kotlin or Java code, stick to classes, which mirror Kotlin/Java’s reference semantics.

We also recommend against bridging transpiled mutable structs to native Swift, as they require a significant amount of object copying on the JVM side.


Powering Your UI

Google recommends Jetpack Compose for Android user interface development. Skip can translate a large subset of SwiftUI into Compose, allowing you to build cross-platform iOS and Android UI in SwiftUI. Or you can write a separate Android UI in pure Compose using your Android IDE of choice.

Regardless of how you build your Compose UI, Skip ensures that the @Observable types you bridge from your native Swift participate in Compose state tracking. This allows them to seamlessly power your Android user interface just as they power your iOS one.

To allow your @Observable type to participate in Compose state tracking, simply ensure that your module is bridged, and make sure to import SkipFuse in your @Observable's Swift file. The SkipStone build plugin will warn you if this import is missing.

In addition to @Observables, your UI layer can of course call other bridged native API as well.

Skip currently supports SwiftUI only in transpiled modules. So to utilize cross-platform SwiftUI in your app, your native Swift business logic and transpiled SwiftUI will be in separate modules. This is the default setup created by skip init --native.


Debugging

Logging

Messages that you print do not appear in Android logging. Instead, SkipFuse includes support for using the standard OSLog.Logger API for dual-platform logging:

import SkipFuse
...
let logger = Logger(subsystem: "my.subsystem", category: "MyCategory")
...
logger.info("My message")

When you log a message in your app, the OSLog messages from the iOS side of the app will appear in Xcode’s console as usual. The Android implementation of OSLog.Logger, on the other hand, forwards log messages to Logcat, which is Android’s native logging mechanism. Using the Logcat tab in Android Studio is a good way to browse and filter the app’s log messages on the Android side. You can also view Logcat output in the Terminal using the command adb logcat, which has a variety of filtering flags that can applied to the default (verbose) output.

Crashes

A crash in native Swift code on Android will surface similarly to a crash caused by an uncaught Kotlin or Java exception. Instead of a detailed stack trace with line numbers, however, you will only receive the mangled name of the Swift function in which the crash took place. Mangled names are close enough to actual Swift class and function names for you to find the offending function.

Following is an abridged sample of the Logcat contents when a crash occurs from a call to fatalError("CRASHME") in native code:

12-02 14:33:26.163 12075 12075 F SwiftRuntime: HelloSwiftModel/ViewModel.swift:92: Fatal error: CRASHME
12-02 14:33:26.171 12075 12075 F libc    : Fatal signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr 0x763f841d04 in tid 12075 (.xyz.HelloSwift), pid 12075 (.xyz.HelloSwift)
12-02 14:33:26.198 12146 12146 I crash_dump64: obtaining output fd from tombstoned, type: kDebuggerdTombstoneProto
12-02 14:33:26.200   207   207 I tombstoned: received crash request for pid 12075
12-02 14:33:26.201 12146 12146 I crash_dump64: performing dump of process 12075 (target tid = 12075)
12-02 14:33:26.636   163   163 I logd    : logdr: UID=10296 GID=10296 PID=12146 n tail=500 logMask=8 pid=12075 start=0ns deadline=0ns
12-02 14:33:26.648   163   163 I logd    : logdr: UID=10296 GID=10296 PID=12146 n tail=500 logMask=1 pid=12075 start=0ns deadline=0ns
12-02 14:33:26.663 12146 12146 F DEBUG   : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
12-02 14:33:26.663 12146 12146 F DEBUG   : Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:14/UE1A.230829.050/12077443:userdebug/dev-keys'
12-02 14:33:26.663 12146 12146 F DEBUG   : Revision: '0'
12-02 14:33:26.663 12146 12146 F DEBUG   : ABI: 'arm64'
12-02 14:33:26.663 12146 12146 F DEBUG   : Timestamp: 2024-12-02 14:33:26.204647577-0500
12-02 14:33:26.663 12146 12146 F DEBUG   : Process uptime: 44s
12-02 14:33:26.663 12146 12146 F DEBUG   : Cmdline: com.xyz.HelloSwift
12-02 14:33:26.663 12146 12146 F DEBUG   : pid: 12075, tid: 12075, name: .xyz.HelloSwift  >>> com.xyz.HelloSwift <<<
12-02 14:33:26.663 12146 12146 F DEBUG   : uid: 10296
12-02 14:33:26.663 12146 12146 F DEBUG   : tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)
12-02 14:33:26.663 12146 12146 F DEBUG   : pac_enabled_keys: 000000000000000f (PR_PAC_APIAKEY, PR_PAC_APIBKEY, PR_PAC_APDAKEY, PR_PAC_APDBKEY)
12-02 14:33:26.663 12146 12146 F DEBUG   : signal 5 (SIGTRAP), code 1 (TRAP_BRKPT), fault addr 0x000000763f841d04
12-02 14:33:26.663 12146 12146 F DEBUG   : Abort message: 'HelloSwiftModel/ViewModel.swift:92: Fatal error: CRASHME'
12-02 14:33:26.663 12146 12146 F DEBUG   :     x0  0087000000000000  x1  00000079939ab7c4  x2  00000079939ab7c4  x3  0000007fcd3d0cd8
12-02 14:33:26.663 12146 12146 F DEBUG   : 392 total frames
12-02 14:33:26.663 12146 12146 F DEBUG   : backtrace:
12-02 14:33:26.663 12146 12146 F DEBUG   :       #00 pc 000000000035dd04  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk!libswiftCore.so (offset 0x999c000) ($ss17_assertionFailure__4file4line5flagss5NeverOs12StaticStringV_SSAHSus6UInt32VtF+560) (BuildId: 2e6f82a13c9518ce846bebc261661833fa34b7de)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #01 pc 00000000000381b0  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C9saveItems33_AA1DA8893D92B109DC6527A80C9D3046LLyyF+1244)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #02 pc 000000000003c63c  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C6_items33_AA1DA8893D92B109DC6527A80C9D3046LLSayAA4ItemVGvW+24)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #03 pc 000000000003c704  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C6_items33_AA1DA8893D92B109DC6527A80C9D3046LLSayAA4ItemVGvs+96)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #04 pc 0000000000038514  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C5itemsSayAA4ItemVGvsyyXEfU_+64)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #05 pc 000000000003aa8c  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C5itemsSayAA4ItemVGvsyyXEfU_TA+24)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #06 pc 000000000000cd04  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk!libswiftObservation.so (offset 0xa3bc000) ($s11Observation0A9RegistrarV12withMutation2of7keyPath_q0_x_s03KeyG0Cyxq_Gq0_yKXEtKAA10ObservableRzr1_lF+88) (BuildId: 0698d39971241cca206c1100e2aa89fe94109d35)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #07 pc 000000000010d844  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0x5cdc000) ($s10SkipBridge11ObservationV0C9RegistrarV12withMutation2of7keyPath_q0_x_s03KeyI0Cyxq_Gq0_yKXEtKAB10ObservableRzr1_lF+336)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #08 pc 000000000003d44c  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C12withMutation7keyPath_q_s03KeyH0CyACxG_q_yKXEtKr0_lF+252)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #09 pc 000000000003c890  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C5itemsSayAA4ItemVGvs+136)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #10 pc 000000000003d260  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04ViewC0C4save4itemyAA4ItemV_tF+456)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #11 pc 0000000000040e98  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) ($s15HelloSwiftModel04Viewc1_B7_save_3yySpySPySo18JNINativeInterfaceVGSgG_Svs5Int64VSvtF+324)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #12 pc 0000000000040d48  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (offset 0xabcc000) (Java_hello_swift_model_ViewModel_Swift_1save_13+8)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #13 pc 0000000000377030  /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: b10f5696fea1b32039b162aef3850ed3)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #14 pc 00000000003605a4  /apex/com.android.art/lib64/libart.so (art_quick_invoke_stub+612) (BuildId: b10f5696fea1b32039b162aef3850ed3)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #15 pc 00000000004906b4  /apex/com.android.art/lib64/libart.so (bool art::interpreter::DoCall<false>(art::ArtMethod*, art::Thread*, art::ShadowFrame&, art::Instruction const*, unsigned short, bool, art::JValue*)+1248) (BuildId: b10f5696fea1b32039b162aef3850ed3)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #16 pc 000000000050a5d4  /apex/com.android.art/lib64/libart.so (void art::interpreter::ExecuteSwitchImplCpp<false>(art::interpreter::SwitchImplContext*)+2380) (BuildId: b10f5696fea1b32039b162aef3850ed3)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #17 pc 00000000003797d8  /apex/com.android.art/lib64/libart.so (ExecuteSwitchImplAsm+8) (BuildId: b10f5696fea1b32039b162aef3850ed3)
12-02 14:33:26.663 12146 12146 F DEBUG   :       #18 pc 00000000000020b8  /data/app/~~sl7j-wAIm5OGrYysAOiRvQ==/com.xyz.HelloSwift-l1KlzOX9df6cdVUNJI0-kw==/base.apk (hello.swift.model.ViewModel.save+0)

To extract a human-readable name from the manged swift function, the xcrun swift-demangle command can be run like so:

zap ~ % echo '$s15HelloSwiftModel04ViewC0C9saveItems33_AA1DA8893D92B109DC6527A80C9D3046LLyyF+1244' | xcrun swift-demangle
HelloSwiftModel.ViewModel.(saveItems in _AA1DA8893D92B109DC6527A80C9D3046)() -> ()+1244

Improving the native Swift debugging experience on Android will be an area of future exploration. In the meantime, we encourage you to debug common Swift logic in the iOS environment in Xcode, where you can set breakpoints and have crashes automatically jump to the code that caused the issue.


Testing

See Skip’s standard testing documentation for details on running your unit tests on Android.

Note that Skip transpiles your XCTest unit tests into JUnit tests for Android, regardless of whether your module is native or transpiled. This means that for now, you can only perform Android tests on native Swift that has been bridged to Kotlin/Java. Unit tests involving unbridged types should be excluded from Android testing.

final class MyNativeSwiftTests: XCTestCase {
    ...
   
    #if !os(Android)
    func testSomeUnbridgedSwift() {
        ...
    }
    #endif 
   
    ...
}

We will offer Android unit testing of unbridged native code in a future release.


Platform Customization

Use conditional compiler directives in your native Swift to define iOS-only or Android-only blocks of code. You may already be familiar with Swift like the following:

#if os(macOS)
...
#else
...
#endif

This works for Android too! The following code will log “Android” when run on Android, and “Darwin” otherwise.

#if os(Android)
logger.log("Android")
#else
logger.log("Darwin")
#endif

With this technique you can specialize your logic for iOS or Android. For example, you might call into a bridged Android service within a #if os(Android) block, or use #if !os(Android) to import frameworks and access functionality available only on Apple devices.

There is, however, one additional consideration. Skip gives you the ability to unit test your native Swift in an Android environment running on your Mac, which can be faster than using the Android emulator. If you choose to test on your Mac, Skip uses a simulated Android environment called Robolectric. Unfortunately, #if os(Android) checks will evaluate to false under Robolectric, even though you should generally be exercising the Android code path. So Skip also defines the ROBOLECTRIC symbol in your Robolectric testing builds. If you want to be sure to take the Android code path whether running on device, emulator, or in Robolectric, use #if os(Android) || ROBOLECTRIC.


Dependencies

Adding a dependency on a native Swift module is no different than adding any Swift module dependency.

The native Swift module itself must have dependencies on:

  • The SkipFuse framework.
  • The SkipStone build plugin.

The native Swift module may additionally depend on:

  • Other Skip modules, whether native or transpiled.
  • Any Swift modules that can compile for Android.

Note that many Swift modules can compile for Android even if they weren’t designed to do so. For example, Apple’s swift-algorithms. Other Swift packages may require only minor changes to get them building. For more information, see porting Swift packages to Android.

Here is the default Package.swift created by the skip init --native command. This example illustrates the required dependencies for a native Swift model module and a transpiled SwiftUI user interface module:

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "hello-swift",
    defaultLocalization: "en",
    platforms: [.iOS(.v17), .macOS(.v14),
    products: [
        .library(name: "HelloSwiftApp", type: .dynamic, targets: ["HelloSwift"]),
        .library(name: "HelloSwiftModel", type: .dynamic, targets: ["HelloSwiftModel"]),
    ],
    dependencies: [
        .package(url: "https://source.skip.tools/skip.git", from: "1.1.24"),
        .package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0"),
        .package(url: "https://source.skip.tools/skip-foundation.git", from: "1.0.0"),
        .package(url: "https://source.skip.tools/skip-fuse.git", "0.0.0"..<"2.0.0")
    ],
    targets: [
        .target(name: "HelloSwift", dependencies: [
            "HelloSwiftModel",
            .product(name: "SkipUI", package: "skip-ui")
        ], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
        .target(name: "HelloSwiftModel", dependencies: [
            .product(name: "SkipFoundation", package: "skip-foundation"),
            .product(name: "SkipFuse", package: "skip-fuse")
        ], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
    ]
)


Deployment

Deploying a Skip app employing native Swift is the same as deploying any other Skip app. See the Deployment chapter for details.


Porting Swift Packages to Android

Many Swift packages will build “out of the box” for Android, and some others will require only small changes to get building. In general, if a package already supports Linux, then getting it building for Android is typically a small task. Oftentimes, simply running skip android build in the package root and seeing the errors or warnings in the console output is sufficient to get a sense of the amount of effort that will be required to get the package building.

The primary consideration for whether a package will be viable on Android is the dependencies that it has. If a package has direct or transitive dependency on a closed-source framework that is only available for Apple platforms, such as CoreGraphics or UIKit, then it will be challenging or impossible to support.

For packages with source that depends on Darwin, which provides access to the standard C library, those imports should be guarded within #if canImport() checks to import the equivalent Android module instead. For example, instead of:

import Darwin

You would have:

#if canImport(Darwin)
import Darwin
#elseif canImport(Android)
import Android
#endif

That will conditionally import the equivalent functionality on both Apple and Android platforms.