Swift 6.3 changes the cross-platform conversation: its first official Swift SDK for Android can cross-compile a Swift package into native Android libraries.
That does not mean you should replace Kotlin or Jetpack Compose. It means the business rules your iOS team already trusts can become a portable core: pricing, validation, state transitions, parsing, algorithms, and other platform-independent behavior. Android keeps its native UI and lifecycle. Swift owns a deliberately small, testable domain boundary.
This guide covers the architecture, interop boundary, tests, and costs I would evaluate before production.
1. What Swift 6.3 Actually Adds
The Swift 6.3 release announcement confirms the first official Android SDK. It supplies the libraries, headers, and SwiftPM configuration needed to cross-compile from macOS or Linux. The app bundles the required Swift runtime components.
Three pieces are involved:
- An open-source Swift toolchain on the build host
- The exactly matching Swift SDK for Android
- Android NDK r27d or later
The Swift.org Android getting-started guide currently demonstrates Swift 6.3.3. Its host toolchain and Android SDK must match; Xcode’s bundled Swift is not a substitute.
Before moving this setup to Swift 6.4, follow the production migration checklist for pinned toolchains and clean SwiftPM builds and revalidate every Android SDK and host-toolchain pairing.
At the time of writing, a representative setup looks like this:
swiftly install 6.3.3
swiftly use 6.3.3
swift sdk install \
https://download.swift.org/swift-6.3.3-release/android-sdk/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE_android.artifactbundle.tar.gz \
--checksum d160cc3206dd1886dae3fef2337af5e25ec034692cd0ec225721c56cc69da7f5
swift sdk list
Then install the supported NDK, set ANDROID_NDK_HOME, and run the SDK’s setup-android-sdk.sh. Recheck Swift.org when upgrading because URLs and checksums change.
You can prove the toolchain before touching an app:
swift build \
--swift-sdk aarch64-unknown-linux-android28 \
--static-swift-stdlib
That proves cross-compilation, not app integration. A real app still needs ABI libraries, Gradle packaging, runtime dependencies, and a Kotlin/Java bridge.
2. Choose a Boundary You Can Defend
The safest shared module is boring: domain models, validation, calculations, use cases, and tests. It has no view controllers, activities, database framework, or opinions about application lifecycle. iOS supplies SwiftUI/UIKit and Apple-service adapters; Android supplies Compose, lifecycle, Keystore, and WorkManager adapters.
This follows MVVM with Clean Architecture in iOS: platform code depends on domain policy, never the reverse. Business rules that import UIKit or access UserDefaults are not portable yet.
Start with one vertical slice that matters but is reversible. Checkout price calculation is a good candidate. Camera capture, push notifications, background scheduling, and navigation are poor first candidates because the platform is the feature.
3. Build the Shared Package as Portable Swift
Keep the SwiftPM domain surface small and value-oriented. Here is a complete pricing rule that can run on iOS, Android, macOS, or Linux:
import Foundation
public struct CartLine: Sendable, Equatable {
public let productID: String
public let unitPriceInCents: Int64
public let quantity: Int
public init(
productID: String,
unitPriceInCents: Int64,
quantity: Int
) {
self.productID = productID
self.unitPriceInCents = unitPriceInCents
self.quantity = quantity
}
}
public enum CheckoutError: Error, Sendable, Equatable {
case emptyCart
case invalidQuantity(productID: String)
case invalidCoupon
case malformedRequest
}
public struct CheckoutQuote: Sendable, Equatable {
public let subtotalInCents: Int64
public let discountInCents: Int64
public let totalInCents: Int64
}
public struct CheckoutCalculator: Sendable {
public init() {}
public func quote(
lines: [CartLine],
coupon: String?
) throws -> CheckoutQuote {
guard !lines.isEmpty else { throw CheckoutError.emptyCart }
var subtotal: Int64 = 0
for line in lines {
guard line.quantity > 0 else {
throw CheckoutError.invalidQuantity(productID: line.productID)
}
let (lineTotal, overflow) = line.unitPriceInCents
.multipliedReportingOverflow(by: Int64(line.quantity))
guard !overflow else {
throw CheckoutError.invalidQuantity(productID: line.productID)
}
let (newSubtotal, sumOverflow) = subtotal
.addingReportingOverflow(lineTotal)
guard !sumOverflow else {
throw CheckoutError.invalidQuantity(productID: line.productID)
}
subtotal = newSubtotal
}
let normalizedCoupon = coupon?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let discount: Int64
switch normalizedCoupon {
case nil, "":
discount = 0
case "SAVE10":
discount = subtotal / 10
default:
throw CheckoutError.invalidCoupon
}
return CheckoutQuote(
subtotalInCents: subtotal,
discountInCents: discount,
totalInCents: subtotal - discount
)
}
}
Integer minor units avoid floating-point currency errors, and Sendable makes concurrency intent explicit. The deeper reasoning behind those value types is covered in Struct vs Class in Swift 6: Concurrency-Safe Choices.
Audit every dependency
Foundation exists on Android, but Apple frameworks such as UIKit, SwiftUI, Core Data, Security, and StoreKit do not become portable. Make Android cross-compilation a CI gate.
One easy-to-miss detail: SwiftPM has no .android(...) entry in Package.platforms. That manifest property declares minimum deployment versions for Apple platforms; it does not prove Android support. A package is Android-compatible only when its sources and every dependency successfully cross-compile for your Android triples. Put those builds in CI instead of treating the absence of a platform declaration as compatibility.
Put platform capabilities behind protocols implemented by each app. Avoid scattering #if os(Android) through domain files.
For larger extractions, follow the dependency rules in SPM Modularization: Scaling Your iOS Codebase: keep domain at the bottom and expose only intentional APIs.
4. Build One End-to-End Swift-to-Kotlin Call
Android APIs live primarily in Kotlin and Java on the Android Runtime. Swift code crosses that boundary through JNI. You can write JNI glue manually, but the official Swift project recommends its swift-java tools for most developers; Swift Java JNI Core is the lower-level option.
The swift-java jextract generator reads Swift sources and produces Java-facing wrappers plus Swift JNI thunks. For Android, use its JNI mode rather than the Foreign Function and Memory mode intended for newer server-side JVMs. The official Swift Android hello-swift-java example is the reference for this section. I pinned the walkthrough to repository commit 6a74d4a48d9cbead70e22c1a3dc76c6804a05d9b, where Swift exports public func hash(_ input: String) -> String and Kotlin calls SwiftHashing.hash(input).
The pinned example’s manifest says from: "0.1.2". That is a lower bound, not the version used by every checkout. The repository does not track hashing-lib/Package.resolved; resolving that exact snapshot on September 6, 2026 selected swift-java 0.6.0 at revision 72f123cd7300b200da82e8facc6ecac0be33324e. Commit your generated Package.resolved (or change the dependency to that revision) if you need to reproduce this walkthrough exactly:
dependencies: [
.package(
url: "https://github.com/swiftlang/swift-java",
revision: "72f123cd7300b200da82e8facc6ecac0be33324e"
)
]
// On the SharedBusiness target:
dependencies: [
.product(name: "SwiftJava", package: "swift-java")
],
plugins: [
.plugin(name: "JExtractSwiftPlugin", package: "swift-java")
]
The package also needs Sources/SharedBusiness/swift-java.config, which the plugin discovers beside the target sources:
{
"javaPackage": "com.example.sharedbusiness",
"mode": "jni"
}
Keep the executable boundary to verified types
At the resolved 0.6.0 snapshot, the official example proves a top-level String -> String function. It does not prove that a richer facade—arrays, throwing methods, nested Swift values, and a long-lived object—will generate the API you expect. Keep those types inside Swift and export one function using the demonstrated boundary:
// Sources/SharedBusiness/AndroidBridge.swift
public func checkout(_ request: String) -> String {
// Format: "unitPrice,quantity;unitPrice,quantity|coupon"
let fields = request.split(
separator: "|",
maxSplits: 1,
omittingEmptySubsequences: false
)
guard let encodedLines = fields.first else {
return "ERROR|MALFORMED_REQUEST"
}
let coupon = fields.count == 2 ? String(fields[1]) : nil
let parsedLines: [CartLine]
do {
parsedLines = try encodedLines.split(separator: ";").enumerated().map {
index, encodedLine in
let values = encodedLine.split(separator: ",")
guard values.count == 2,
let price = Int64(values[0]),
let quantity = Int(values[1]) else {
throw CheckoutError.malformedRequest
}
return CartLine(
productID: "android-\(index)",
unitPriceInCents: price,
quantity: quantity
)
}
let total = try CheckoutCalculator()
.quote(lines: parsedLines, coupon: coupon)
.totalInCents
return "OK|\(total)"
} catch CheckoutError.emptyCart {
return "ERROR|EMPTY_CART"
} catch CheckoutError.invalidCoupon {
return "ERROR|INVALID_COUPON"
} catch CheckoutError.invalidQuantity(_) {
return "ERROR|INVALID_QUANTITY"
} catch {
return "ERROR|MALFORMED_REQUEST"
}
}
This is intentionally a humble wire format. A production team may choose versioned JSON, but that is a separate compatibility decision. The important property is explicit behavior: no claim is made that a thrown Swift error automatically becomes a Java/Kotlin exception. The exported function catches every Swift error and returns either OK|<total> or ERROR|<code>.
The primary build flow is the SwiftPM plugin, exactly as in the pinned official example. In that unchanged example, Gradle runs swift build; JExtractSwiftPlugin reads Sources/SwiftHashing/swift-java.config and writes Java sources under:
.build/plugins/outputs/hashing-lib/SwiftHashing/
destination/JExtractSwiftPlugin/src/generated/java
Renaming the package directory and target changes those two path components. The same build compiles the generated Swift thunks into the dynamic library.
When debugging the generator alone, the resolved 0.6.0 executable product is named swift-java—not swift-java-tool. Its verified flags permit this diagnostic command:
swift run --package-path .build/checkouts/swift-java swift-java jextract \
--config Sources/SharedBusiness/swift-java.config \
--mode=jni \
--swift-module SharedBusiness \
--input-swift Sources/SharedBusiness \
--output-java /tmp/shared-business-jextract/java \
--output-swift /tmp/shared-business-jextract/swift
That command is debugging-only. Gradle never consumes its /tmp output. Package.swift, domain sources, the Kotlin adapter, and Gradle configuration are handwritten; Java wrappers and Swift JNI thunks are generated and must not be hand-edited.
For this top-level function, the generated Kotlin-visible surface follows the official example’s verified form:
// Shape of the generated Java API consumed from Kotlin:
public final class SharedBusiness {
public static String checkout(String request);
}
Kotlin can give the result a normal platform-owned type:
import com.example.sharedbusiness.SharedBusiness
sealed interface CheckoutResult {
data class Success(val totalInCents: Long) : CheckoutResult
data class Failure(val code: String) : CheckoutResult
}
class CheckoutRepository {
fun total(request: String): CheckoutResult {
val fields = SharedBusiness.checkout(request).split('|', limit = 2)
return when (fields.firstOrNull()) {
"OK" -> fields.getOrNull(1)?.toLongOrNull()
?.let(CheckoutResult::Success)
?: CheckoutResult.Failure("MALFORMED_RESPONSE")
"ERROR" -> CheckoutResult.Failure(
fields.getOrNull(1) ?: "UNKNOWN"
)
else -> CheckoutResult.Failure("MALFORMED_RESPONSE")
}
}
}
There is no handwritten loader. In swift-java 0.6.0 JNI mode, the generated SharedBusiness class defines LIB_NAME = "SharedBusiness". Its static initializer calls SwiftLibraries.loadLibraryWithFallbacks("SwiftJava") and then SwiftLibraries.loadLibraryWithFallbacks("SharedBusiness"); loading SwiftLibraries initializes Swift core support first. Calling SharedBusiness.checkout(...) triggers that generated order. Replacing it with a custom System.loadLibrary block risks loading the module before its runtime dependencies.
Package the native library with Gradle
The official example’s library module cross-compiles arm64-v8a, armeabi-v7a, and x86_64, copies libSwiftHashing.so, libc++_shared.so, and required Swift runtime .so files into build/generated/jniLibs, and packages everything as an AAR. Crucially, its Java source set points at the plugin task output, not a hand-chosen generated directory. This is the relevant code from the pinned example, with its original names intact:
android {
namespace "com.example.hashinglib"
compileSdkVersion 34
defaultConfig { minSdkVersion 28 }
}
dependencies {
implementation('org.swift.swiftkit:swiftkit-core:+')
}
def buildSwiftAll = tasks.register("buildSwiftAll") {
outputs.dir(layout.buildDirectory.dir(
"../.build/plugins/outputs/" +
"${layout.projectDirectory.asFile.getName().toLowerCase()}/" +
"SwiftHashing/destination/JExtractSwiftPlugin/src/generated/java"
))
}
android {
sourceSets {
main {
java.srcDir(buildSwiftAll)
jniLibs.srcDir(layout.buildDirectory.dir("generated/jniLibs"))
}
}
}
preBuild.dependsOn(copyJniLibs)
The + is quoted exactly from the pinned example, but it is a reproducibility risk because Gradle may select a different locally published build later. Lock that dependency in your adapted project once the SwiftKit artifact’s versioning is stable in your pipeline.
The omitted ABI tasks make buildSwiftAll depend on three swift build --swift-sdk <triple> --build-system native commands, while copyJniLibs depends on buildSwiftAll. The official app module uses implementation(project(":hello-swift-java-hashing-lib")); first referencing SwiftHashing performs the generated library loading. At this snapshot, SwiftKitCore must first be published to local Maven. Run the pinned example unchanged first, using its documented commands:
swift package resolve
./.build/checkouts/swift-java/gradlew \
--project-dir .build/checkouts/swift-java \
:SwiftKitCore:publishToMavenLocal
./gradlew :hello-swift-java-hashing-lib:assembleRelease
The abbreviated block is not standalone: the full pinned hashing-lib/build.gradle owns ABI mapping, Swift compilation, runtime copying, and task dependencies. Run it unchanged, then rename SwiftHashing, its Java package, and its Gradle module in a small reviewable diff.
The official sample declares minSdk 28 and compiles Swift with triples ending in android28. Those are different facts: the triple selects the NDK/API compilation target, while minSdk controls where Android may install the app. Neither proves every interop path works on an API 28 runtime. For the pinned example commit resolved with swift-java 0.6.0, API 28 is the configured minimum; make an API 28 emulator/device call checkout in a release build before claiming it as your supported minimum, then repeat for every minimum you publish.
This executable sample exports no class, actor, callback, or long-lived Swift reference—only a function receiving and returning copied strings. There is therefore no generated Swift object handle for Kotlin to release. Once you export reference types, inspect that pinned generator’s ownership API and test cleanup explicitly; do not guess that JVM garbage collection releases Swift ARC objects promptly.
Treat the bridge like a network boundary
Prefer strings, integers, byte buffers, and coarse-grained operations. Translate Swift errors into a small documented result model, as above. Avoid chatty APIs where Kotlin calls Swift once per list cell, and avoid passing a rich graph of reference objects across JNI.
A useful rule is: one user intent, one boundary crossing. Send the cart into Swift, compute the quote, and return a result. Do not cross into Swift separately for every line, discount, tax component, and label.
5. Concurrency, Ownership, and Performance
Swift ARC and JVM garbage collection are separate ownership systems, so bidirectional object graphs can leak across their boundary. Prefer short-lived values; when exporting references, document ownership and exercise the generator’s cleanup API.
A call arrives on its Kotlin caller’s thread unless you schedule otherwise. Swift actors do not move Compose work to Android’s main looper, so Kotlin must perform its UI hop. JNI transitions, string conversion, copying, and wrapper allocation also cost time: batch work and profile release builds on representative devices. Finally, compare AAB and installed size because each ABI needs native libraries and Swift runtime components.
6. Test the Package and the Boundary Separately
Most business behavior should remain ordinary Swift tests:
import Testing
@testable import SharedBusiness
struct CheckoutCalculatorTests {
@Test("SAVE10 applies a ten percent discount")
func appliesCoupon() throws {
let line = CartLine(
productID: "pro",
unitPriceInCents: 12_500,
quantity: 2
)
let quote = try CheckoutCalculator().quote(
lines: [line],
coupon: " save10 "
)
#expect(quote.subtotalInCents == 25_000)
#expect(quote.discountInCents == 2_500)
#expect(quote.totalInCents == 22_500)
}
}
Run the suite on the host for fast feedback, then compile and exercise the Android target. The blog’s Swift Testing guide for senior engineers covers parameterized cases and concurrency-aware test design if your shared rules grow more complex.
Your CI matrix should have distinct gates:
- Host
swift testfor domain behavior - Android cross-compilation for every shipped ABI
- Binding generation with a clean-tree check
- Gradle unit tests for Kotlin adapters
- Instrumented tests on at least one emulator or device
- A release-build smoke test that loads the native library and calls one exported operation
The fifth and sixth gates catch failures a host test cannot: missing .so files, wrong ABI packaging, JNI symbol problems, Android API-level differences, and lifetime issues that appear only under ART.
7. What You Should Not Share
Keep Compose, SwiftUI/UIKit, navigation, accessibility, permissions, notifications, secure storage, and lifecycle handling native. These APIs embody platform conventions.
Networking and persistence need judgment. Sharing request models, validation, retry policy, and mapping can help, while native transports and databases preserve platform-specific certificate handling, background behavior, and storage. Share policy behind protocols; keep URLSession/OkHttp and Core Data/Room in their platform adapters.
8. Production Tradeoffs in 2026
The official SDK is credible, but swift-java offers no API-stability guarantee before 1.0. Budget for:
- Android developers need enough Swift literacy to debug the shared layer.
- iOS developers need enough Gradle, NDK, and JNI knowledge to own packaging failures.
- Native crash symbolication needs rehearsal on both platforms.
- Toolchain upgrades become coordinated changes, not casual version bumps.
- Third-party packages and minimum Android versions require on-device verification.
For an iOS-heavy team with proven Swift logic, that may be worthwhile. In a balanced greenfield team, duplicating a modest domain layer can remain cheaper. Measure defects prevented, integration hours, app size, call latency, and upgrade effort—not shared lines of code.
9. A Sensible Adoption Plan
Adopt it through one reversible vertical slice:
- Extract a pure Swift package and remove Apple-only imports.
- Add host tests, Android cross-compilation, and pinned tools to CI.
- Export one coarse-grained function and hide it behind a Kotlin repository.
- Test packaging, threading, errors, ownership, and release performance on devices.
- Ship a limited feature with a fallback; expand only when measurements justify it.
If integration proves too fragile, the package is still a cleaner iOS module and Kotlin can replace the narrow facade.
10. Conclusion
Swift on Android is now a serious engineering option, not yet a universal default. Used with discipline, it can let an iOS-heavy team reuse years of reliable domain code without making Android feel like a port. The winning architecture is intentionally uneven: share the rules that must stay identical, and let each platform remain excellent at being itself.
Key Takeaways
- Swift 6.3 provides the first official Swift SDK for Android and produces native Android code.
- The highest-value target is portable business logic, not a shared cross-platform UI.
- Keep a small, coarse-grained Swift facade and use swift-java’s JNI mode from Kotlin.
- Test Swift behavior, bindings, ABI packaging, and on-device runtime behavior separately.
- Treat pre-1.0 interop tooling as replaceable and expand sharing only from measured success.