Senior iOS Software Engineer

Cologne, Germany

Mechanical bird in flight, symbolising the bridge from Swift to Android

Swift for Android: A First Glance

TL;DR

  • Swift 6.3 ships an official Android SDK.
  • Java interop runs over JNI; FFM is not supported on Android.
  • The tooling generates the bridge code for you.
  • Java-to-Swift is the default; Swift-to-Java callbacks are opt-in.
  • KMP has been stable since 2023; the Swift Android SDK shipped in 2026.
  • No UI layer.
  • IMHO, not production-ready yet.

Swift 6.3 ships with an official Android SDK. This post walks through what that enables, the tooling around it, the bridge to Java, and where the whole thing stands today.

History

  • 2014: Swift released by Apple, initially as the new language for iOS and macOS.
  • 2016: Kotlin arrives on Android as a JetBrains-built JVM language. Google declares it a first-class Android language in 2017.
  • 2023: Kotlin Multiplatform1 reaches stable, allowing Kotlin code to be shared across iOS, Android, and the JVM.
  • 2024: swift-java is announced, originally aimed at calling Java from Swift on the server JVM.
  • 2026: Swift 6.3 ships with the official Android SDK, and the same swift-java tooling becomes the bridge to Android.

Why Android at all?

Mobile teams routinely build the same feature twice: once in Swift for iOS, once in Kotlin for Android. The business logic gets duplicated across two codebases and often diverges over time.

The wish for shared code is older than mobile itself. Stakeholders want consistency and lower cost. Engineers want a single source of truth for the parts of the app that have nothing to do with the UI. The existing answers are Kotlin Multiplatform, Flutter, and React Native, each making a different trade-off between shared logic, shared UI, and platform fidelity.

Swift on Android adds another answer, but a narrow one. There is no shared UI layer. The realistic target is headless code: a Swift library that holds business logic, dropped into an Android app as a native shared library and called from Kotlin.

What you need

The Swift project documents two sides of the workflow: cross-compiling on a host machine, and deploying to Android.

For cross-compilation you need three things on a macOS or Linux host: the Swift toolchain (the official getting-started guide uses 6.3.2), the Swift SDK for Android (a separate bundle of Swift libraries, headers, and configuration that extends the toolchain), and the Android NDK2 in LTS version 27d or later. The getting-started guide targets Android API level 28, with target triples like aarch64-unknown-linux-android28 and x86_64-unknown-linux-android28.

To run the resulting binary, you need either a physical Android device with USB debugging enabled or a locally-running Android emulator (typically installed via Android Studio). adb3 handles the deployment and lets you stream logs.

See the Swift SDK for Android getting-started guide for the canonical instructions.

Bridging Swift and Java

Why JNI instead of FFM

JNI is how the JVM talks to native code: a Java method is bound to a C-style symbol exported by a native library.

FFM, the Foreign Function and Memory API, is the modern replacement. The name reflects that it covers two concerns: calling foreign functions, and safely accessing memory that the JVM does not own. Compared to JNI it is less brittle, gives Java a typed view of native memory, and needs no hand-written C glue on the Java side.

On a backend or desktop JVM, FFM is the way to go. The Android Runtime4 does not implement it, so JNI remains the only option on Android. This is why swift-java’s Android output uses --mode=jni while the server JVM target uses --mode=ffm.

What swift-java is

swift-java is the umbrella project that provides the bridge. It started as a Swift Server Workgroup effort for Swift-to-Java interop on the JVM and now doubles as the Swift-on-Android bridge: the swift-android-examples repository uses it for every example that calls Swift from Kotlin.

It supports two modes:

  • --mode=jni for Android, since the Android Runtime has no FFM.
  • --mode=ffm for a backend or desktop JVM that does have it.

Everything below assumes the Android case, so --mode=jni.

Calling Java from Swift

Manual JNI

You write a Swift function with @_cdecl and a mangled name matching what JNI expects, declare a native method on the Java side, and let System.loadLibrary pull in the .so at runtime. A raw-JNI example shows this end-to-end. You own everything: symbol names, JNI marshalling, lifetime.

Annotation-driven via swift-java

Instead of writing the bridge by hand, you annotate your Swift code and let swift-java generate both sides of the bridge. Five annotations cover the common cases:

  • @JavaClass declares that a Swift type mirrors a Java class.
  • @JavaMethod exposes a Java instance method to Swift.
  • @JavaStaticMethod exposes a Java static method to Swift.
  • @JavaField exposes a Java field to Swift.
  • @JavaImplementation provides the Swift body of a method declared on the Java side.

Protocol-based callbacks

A second variant skips the annotations entirely: a Swift protocol becomes a Java interface that Kotlin or Java can implement, and instances can be passed back into Swift. No @Java* annotations needed; the protocol declaration alone is enough. See Swift calling Java back for the full treatment.

swift-java tooling

You do not install or invoke the codegen tools directly. swift-java is added as a Swift Package dependency, ships a SwiftPM build plugin (JExtractSwiftPlugin) that runs the generators during swift build, and provides a runtime library (SwiftJava) that the Swift target links against. The hashing-lib example in swift-android-examples, a small Swift package exposing a SHA-256 hash function (examined in detail further down), wires this up in its Package.swift:

dependencies: [
  .package(url: "https://github.com/swiftlang/swift-java", from: "0.1.2"),
],
targets: [
  .target(
    name: "SwiftHashing",
    dependencies: [.product(name: "SwiftJava", package: "swift-java")],
    plugins: [.plugin(name: "JExtractSwiftPlugin", package: "swift-java")]
  ),
]

The Java side gets a matching runtime artifact (org.swift.swiftkit:swiftkit-core), pulled in as a regular Gradle dependency on the Android module.

Internally, three code generators are involved:

  • swift-java produces the Swift @_cdecl bridge.
  • jextract-swift produces the Java native wrapper. --mode=jni for Android, --mode=ffm for the server JVM.
  • wrap-java produces Swift wrappers for Java APIs.

Pipeline integration

On the Swift side, a single swift build is enough: the plugin produces both the bridge file and the tree of generated Java wrapper classes as part of the normal build.

On the Android side, a Gradle module ties the Swift output into the app build. It cross-compiles the Swift package once per supported Android architecture, places each resulting shared library in the standard jniLibs/ folder, and folds the generated Java wrappers into the Android source set. The hashing-lib example demonstrates this end-to-end, with the Swift runtime libraries (swiftCore, swift_Concurrency, Foundation, dispatch, and a handful more) copied alongside the application code. The final output is a standard Android .aar that the consuming app picks up as a local dependency.

The Swift runtime has to ship with the APK because Android does not provide one. If the Swift code pulls in a module like Observation, the corresponding libswiftObservation.so has to be added to the Gradle library list explicitly; otherwise the app crashes at startup with an UnsatisfiedLinkError.

Manual vs pipeline

Nothing the SwiftPM plugin and the Gradle script do is magic. They automate exactly the manual JNI workflow shown in the raw-JNI example. The plugin emits the same shape of @_cdecl Swift functions and native Java declarations you would write by hand, just consistently and without the symbol-mangling busywork.

A concrete example: hashing-lib

Build flow

Diagram of the build pipeline that turns a Swift source file into both a native Android library and a matching Java wrapper, which the Android app then loads and calls at runtime.

The pipeline produces three artifacts from one Swift source file:

  1. swift-java reads SwiftHashing.swift and writes SwiftHashingModule+SwiftJava.swift, the @_cdecl bridge.
  2. jextract-swift reads the same SwiftHashing.swift and writes SwiftHashing.java, the Java wrapper.
  3. swift build compiles the bridge into libSwiftHashing.so.

On the Android side, MainActivity.kt consumes SwiftHashing.java directly and loads libSwiftHashing.so through System.loadLibrary.

Swift code

The library itself is one function that hashes a string with SHA-256. The conditional import is worth noting: FoundationEssentials is a lighter, ICU-free Foundation re-implementation that the code prefers when available, falling back to full Foundation otherwise. The pattern keeps the library portable across Apple, Linux server, and Android targets.

// Sources/SwiftHashing/SwiftHashing.swift
import Crypto
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

public func hash(_ input: String) -> String {
    return SHA256.hash(data: Data(input.utf8)).description
}

Generated Swift bridge

This is the bridge file that makes the Swift function visible to JNI. swift-java generates it from the source above, with a symbol name encoded the way JNI expects, so you do not write it by hand:

// .build/plugins/outputs/.../JExtractSwiftPlugin/Sources/SwiftHashingModule+SwiftJava.swift
@_cdecl("Java_com_example_swifthashing_SwiftHashing__00024hash__Ljava_lang_String_2")
public func Java_com_example_swifthashing_SwiftHashing__00024hash__Ljava_lang_String_2(
  environment: UnsafeMutablePointer<JNIEnv?>!, thisClass: jclass, input: jstring?
) -> jstring? {
  return SwiftHashing.hash(String(fromJNI: input, in: environment))
    .getJNILocalRefValue(in: environment)
}

About @_cdecl

The @_cdecl attribute is not new. It has been the de-facto way for years to expose Swift functions to C code, for example when embedding Swift in a C or Objective-C project, though it remains an underscored, unofficial Swift attribute. It exports a Swift function under a fixed C-compatible symbol name instead of Swift’s default mangled one. That fixed name is exactly what JNI’s resolution mechanism looks for: it encodes the Java package, class, and method that should resolve to this function.

Generated Java wrapper

The matching Java class loads the native library on first access and exposes a public hash method that forwards to the JNI-bound private $hash:

// .build/plugins/outputs/.../JExtractSwiftPlugin/src/generated/java/com/example/swifthashing/SwiftHashing.java
public final class SwiftHashing {
  static final String LIB_NAME = "SwiftHashing";

  // loads libSwiftHashing.so when the class is accessed
  static {
    System.loadLibrary(SwiftLibraries.LIB_NAME_SWIFT_JAVA);
    System.loadLibrary(LIB_NAME);
  }

  // friendly API for Kotlin/Java
  public static java.lang.String hash(java.lang.String input) {
    return SwiftHashing.$hash(input);
  }

  // JNI binding to the @_cdecl stub
  private static native java.lang.String $hash(java.lang.String input);
}

Usage

From the Android side, calling Swift looks like any Kotlin method call:

// src/main/java/com/example/hashingapp/MainActivity.kt
Button(
    colors = ButtonDefaults.buttonColors(
        containerColor = Color(0xFFF05138),
        contentColor = Color.White
    ),
    onClick = {
        // This calls the Swift method `hash` from SwiftHashing.swift
        hashResult.value = SwiftHashing.hash(input.value)
    }
) {
    Text("Hash")
}

Swift calling Java back

In the swift-java-weather-app example, the Swift WeatherClient is initialised with a LocationFetcher protocol. On iOS this protocol would be backed by CLLocationManager; on Android, swift-java lets a Java class implement the same protocol and pass the implementation back to Swift. The Swift call to the protocol method then crosses JNI in the opposite direction and lands in the Java implementation.

On the Android side, the implementation is a regular Kotlin class that implements the generated LocationFetcher interface:

// src/main/java/com/example/weatherapp/services/LocationService.kt
class LocationService(private val context: Context) : LocationFetcher {
    private val arena = SwiftArena.ofAuto()

    override fun currentLocation(`swiftArena$`: SwiftArena?): Location {
        // ... fetch Android location via FusedLocationProviderClient ...
        return Location.init(
            androidLocation.latitude,
            androidLocation.longitude,
            arena
        )
    }
}

The swiftArena$ parameter is injected by the codegen: the returned Location is a Swift value, so the Java side needs an arena to allocate it into.

Because reverse calls add memory and thread-lifecycle complexity (Swift’s ARC5 and the JVM’s garbage collector have to agree on when the bridged objects die), the feature is opt-in. You enable it in the package’s swift-java.config:

// Sources/WeatherLibrary/swift-java.config
{
  "javaPackage": "com.example.weatherlib",
  "mode": "jni",
  "enableJavaCallbacks": true
}

The lifetime management this enables is handled through SwiftArena, covered next.

SwiftArena

Calling Swift from Java means Swift objects live on the JVM side of the boundary, and Swift uses ARC where the JVM uses a garbage collector. The two models do not co-operate automatically. SwiftArena (in org.swift.swiftkit.core) is swift-java’s answer: an explicit lifetime region that owns the Swift-side memory of every Swift object that crosses into Java. Closing the arena, explicitly or automatically through SwiftArena.ofAuto(), releases the tracked objects.

This costs more than it does in pure Swift, especially for value types. A struct normally lives inline and gets copied on assignment; the boundary changes that, because the Java side needs a stable handle, so swift-java heap-allocates the struct and pins its lifetime to an arena. Classes don’t pay extra heap allocation, since their instances were already on the heap. Structs do: the boundary forces them out of their inline storage. The weather example shows this pattern: a Swift struct Location becomes a Java final class Location that holds a pointer to the Swift-side storage and registers with a SwiftArena.

The arena shows up in every generated constructor and method signature, so the lifetime is visible at every call site:

// src/main/java/com/example/weatherapp/viewmodel/WeatherViewModel.kt
class WeatherViewModel : ViewModel() {
    private val arena = SwiftArena.ofAuto()

    fun fetchWeather() {
        val client = WeatherClient.init(locationService, arena)
        val weather = client.getWeather(arena).await()
    }
}

On Android, SwiftArena.ofAuto() is the natural choice because Jetpack Compose’s ViewModel already owns the lifecycle: the arena lives as a field on the view model, and once the view model is cleared and garbage-collected, the auto-managed arena tears itself down and frees the Swift objects with it.

Swift for Android vs. KMP on iOS

The two technologies approach the problem from opposite ends. KMP keeps the shared code in Kotlin and runs it as a native framework on iOS, with the iOS side calling into JetBrains-generated Swift bindings. Swift for Android does the reverse: the shared code stays in Swift, gets compiled to a native .so, and is called from Kotlin or Java through swift-java’s generated bridge.

Swift for AndroidKMP on iOS
Memory managementARC5Kotlin/Native garbage collector
Native artifact.so via LLVM6.framework via LLVM
StabilitySDK since March 2026Stable since November 2023
UINo UI layerCompose Multiplatform

Limitations

Known constraints as of Swift 6.3:

  • No UI layer. SwiftUI and UIKit are not available on Android. The Android Workgroup is focused on business logic first; UI options are deferred.
  • No FFM on Android. The Android Runtime does not implement FFM, so JNI is the only interop path.
  • swift-java is pre-1.0. Java-to-Swift callbacks are opt-in via SwiftArena, and the public surface is still moving.

When would I use this?

The realistic use case today is sharing business logic between iOS and Android. The Swift module is dropped into the Android app as a native library and called from Kotlin.

What this is not yet suitable for:

  • A complete cross-platform app, since no UI framework is available on Android.
  • Performance-sensitive paths with frequent crossings, since each call pays JNI overhead. The same applies to KMP and other cross-language sharing approaches.
  • Production code where toolchain stability matters more than the shared codebase, given how new the official SDK is.

Conclusion & Outlook

In my humble opinion, it is not production-ready yet.

What is worth watching over the next releases:

  • How much of Foundation lands on Android and how stable the SDK distribution becomes.
  • Whether swift-java moves towards a 1.0 with a stable surface.
  • Whether Apple takes a more visible position on Swift beyond Apple platforms.

For now, this is interesting enough to prototype with, and not stable enough to bet a shipping product on.


This post is the written version of the short talk I gave at the CocoaHeads Cologne relaunch in April 2026.

Sources

Footnotes

  1. KMP — Kotlin Multiplatform: JetBrains’ technology for sharing Kotlin code across platforms, including iOS, Android, and the JVM.

  2. NDK — Android Native Development Kit: the toolchain that Android uses to build native code. It provides the C standard library, the linker, and the sysroot that Swift’s Android SDK targets.

  3. adb — Android Debug Bridge: a command-line tool that communicates with a connected Android device or emulator, used to install apps and stream logs.

  4. ART — Android Runtime: the managed runtime that executes Android apps. It replaced Dalvik in Android 5.0.

  5. ARC — Automatic Reference Counting: Swift’s memory management model. The compiler inserts retain and release calls at compile time, instead of relying on a garbage collector at runtime. 2

  6. LLVM: a compiler infrastructure. Both the Swift compiler and the Kotlin/Native compiler use it as their backend to produce native code.