Native Swift on Android, Part 3: Sharing a Swift Model Layer
Native Swift on Android, Part 3: Sharing a Swift Model Layer
Introduction
This is the third installment in our series exploring native Swift on Android. In Part 1 we discuss bringing the Swift compiler and toolchain to Android. Part 2 introduces Skip’s tooling and technology for Swift Android app development and leads you through the creation of your first cross-platform Swift app.
If you haven’t already, we highly recommend reading Part 2 to familiarize yourself with Swift-on-Android app development.
The app we create in Part 2 uses a compiled Swift model layer and a shared SwiftUI interface, which Skip transpiles to Jetpack Compose on Android. The following diagram illustrates this dual-platform, single-codebase architecture:
In this article, by contrast, we create separate iOS and Android apps. The iOS app and shared model layer are written in Swift and SwiftUI using Xcode. The Android app is written in Kotlin and Jetpack Compose using Android Studio, and it imports the compiled Swift model as a dependency. This structure allows you to reuse the lower layers of your app logic while fully embracing the standard IDEs and UI toolkits on each platform:
Thanks to Skip’s ability to move fluidly between SwiftUI and Compose, you could also choose to share parts of your SwiftUI interface between iOS and Android while maintaining the rest separately.
TravelPosters
Our sample apps in this installment are iOS and Android versions of TravelPosters
, a simple scrolling grid displaying posters of famous cities. Each poster displays the city’s name and current temperature. You can mark your favorites, and these favorites are remembered across app launches.
The Shared Model
Our shared TravelPostersModel
, therefore, has the following responsibilities:
- Provide a list of cities. Each city must supply its name and a poster image URL.
- Fetch the current temperature for each city.
- Allow the addition and removal of cities from an observable set of favorites.
- Persist and restore the set of favorites across uses of the app.
And given that our model will power both iOS and Android apps, we should add the following table-stakes Android requirements:
- We must be able to access our Swift model API naturally in Kotlin, just as in Swift.
- Our mutable set of favorites must be observable not only to SwiftUI state tracking, but to Jetpack Compose state tracking as well.
Fortunately, Swift is more than up to the task of meeting our model’s general requirements, and Skip’s SkipFuse technology will handle transparently bridging it all to Kotlin and Compose!
Installing Skip
If you plan on following along and you haven’t already installed Skip, follow Part 2’s installation instructions. This will quickly get you up and running with Skip, its requirements, and the native Swift Android toolchain.
You can find the completed sample including iOS and Android apps at https://github.com/skiptools/skipapp-travelposters-native.
Creating the Model Package
As a good, modern citizen of the Swift ecosystem, Skip works atop Swift Package Manager. Our shared model will be a Swift package configured to use skipstone
, the Skip build plugin. You could create this package and configure its use of Skip by hand, but Skip provides tooling to help.
First, create the folder structure we’ll use to hold our shared model as well as our iOS and Android apps. You do not have to house your apps together, but this is the structure we’ll use in this article.
mkdir travelposters
cd travelposters
mkdir iOS
mkdir Android
Now use the skip
tool to create the shared model package:
skip init --native travel-posters-model TravelPostersModel
This command generates a travel-posters-model
SwiftPM package containing the TravelPostersModel
Swift module. The --native
option ensures that the module will already be configured to compile natively on Android, and to bridge its public API to Kotlin. Our particular needs, however, require a couple of additional steps.
-
We know that parts of our model will be
@Observable
. In order for@Observables
to work on Android, we need a dependency onskip-model
. Edit the generatedPackage.swift
to add it:... let package = Package( name: "travel-posters-model", ... dependencies: [ .package(url: "https://source.skip.tools/skip.git", from: "1.2.0"), .package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"), // <-- Insert .package(url: "https://source.skip.tools/skip-fuse.git", "0.0.0"..<"2.0.0") ], targets: [ .target(name: "TravelPostersModel", dependencies: [ .product(name: "SkipFuse", package: "skip-fuse"), .product(name: "SkipModel", package: "skip-model") // <-- Insert ], plugins: [.plugin(name: "skipstone", package: "skip")]), ... ] )
-
The
--native
option we passed toskip init
will configure Skip to automatically bridge our model’s public API from compiled Swift to Android’s ART Java runtime. This is done through theskip.yml
configuration file included in every Skip module. By default, however, Skip assumes that you’ll be bridging to transpiled Swift and SwiftUI code. Instead, we’ll be consuming the model from pure Kotlin, so we want to optimize the bridging for Kotlin compatibility. We do this by editing theSources/TravelPostersModel/Skip/skip.yml
file to look like this:skip: mode: 'native' bridging: enabled: true options: 'kotlincompat'
You can read more about the magic of bridging in the documentation.
With these updates in place, we’re now ready to iterate on our shared Swift model code!
Exploring the Code
The beauty of cross-platform Swift code is how boring it is. You can browse our model’s complete content on GitHub, but it looks more or less exactly as you’d expect given the previously-enumerated requirements. It has some Codable
structs to represent cities and weather:
public struct City : Identifiable, Codable {
public typealias ID = Int
public let id: ID
public let name: String
public let imageURL: URL
...
}
public struct WeatherConditions : Hashable, Codable {
public let temperature: Double // 16.2
public let windspeed: Double // 16.6
...
}
These structs are used in other Skip samples as well, so they contain more information than we strictly need for TravelPosters
.
The model uses URLSession
and JSONDecoder
to fetch the current weather:
public struct Weather : Hashable, Codable {
public let latitude: Double // e.g.: 42.36515
public let longitude: Double // e.g.: -71.0618
public let time: Double // e.g.: 0.6880760192871094
...
public let conditions: WeatherConditions
enum CodingKeys: String, CodingKey {
case latitude = "latitude"
case longitude = "longitude"
case time = "generationtime_ms"
...
case conditions = "current_weather"
}
public static func fetch(latitude: Double, longitude: Double) async throws -> Weather {
let factor = pow(10.0, 4.0) // API expects a lat/lon rounded to 4 places
let lat = Double(round(latitude * factor)) / factor
let lon = Double(round(longitude * factor)) / factor
let url = URL(string: "https://api.open-meteo.com/v1/forecast?latitude=\(lat)&longitude=\(lon)¤t_weather=true")!
var request = URLRequest(url: url)
request.setValue("skipapp-sample", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Weather.self, from: data)
}
}
And it includes an @Observable CityManager
to provide the list of cities and to persist favorites:
@Observable public final class CityManager {
private static let favoritesURL = URL.applicationSupportDirectory.appendingPathComponent("favorites.json")
public static let shared = CityManager()
private init() {
do {
self.allCities = try JSONDecoder().decode([City].self, from: localCitiesJSON.data(using: .utf8)!).sorted { c1, c2 in
c1.name < c2.name
}
} catch {
logger.log("error loading cities: \(error)")
}
do {
self.favoriteIDs = try JSONDecoder().decode([City.ID].self, from: Data(contentsOf: Self.favoritesURL))
logger.log("loaded favorites: \(self.favoriteIDs)")
} catch {
logger.log("error loading favorites: \(error)")
}
}
public var allCities: [City] = []
public var favoriteIDs: [City.ID] = [] {
didSet {
logger.log("saving favorites: \(self.favoriteIDs)")
do {
try FileManager.default.createDirectory(at: Self.favoritesURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(favoriteIDs).write(to: Self.favoritesURL)
} catch {
logger.log("error saving favorites: \(error)")
}
}
}
}
private let localCitiesJSON = """
...
"""
While this code is generally pretty standard, it does contain a few concessions to the realities of current Swift support on Android:
-
In files that create an
OSLog.Logger
or that define an@Observable
type, we alsoimport SkipFuse
. In fact, Skip will surface a build warning in Xcode if you attempt to define an@Observable
in a bridged file that doesn’t import the SkipFuse framework!SkipFuse is an umbrella framework that “fuses” the Swift and Android worlds. It makes sure that your
OSLog
messages are routed to Android’s Logcat logging service, that your@Observable
state is tracked by Jetpack Compose, and more - all without changes to your normal code path. You can read more about SkipFuse here. -
You may notice other unfamiliar
import
patterns as well. For example,Foundation
on Linux and Android is divided intoFoundation
,FoundationNetworking
,FoundationInternationalization
, andFoundationXML
. So inWeather.swift
where we useURLSession
, we have the following imports:import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif
- Though we do not need them here, you may encounter
#if os(Android)
checks to conditionalize code for Android or Darwin platforms in other Android-supporting codebases, just as you’ll often find#if os(macOS)
conditions in macOS-supporting codebases. - We’re loading our cities JSON from a static string instead of a bundled file because native resource loading is not yet available. This is high on our priority list to add to SkipFuse.
- While many Swift packages like Apple’s swift-algorithms compile cleanly for Android out of the box, others will require minor changes, and still others - particularly those that tie into the hardware or use one of Apple’s many OS “Kits” - may never work on Android. Swift on Android is still in its infancy, and it will take time for developers to build and test their packages on this new-to-Swift platform.
Just because a package isn’t yet available on Android doesn’t mean you can’t use it in your iOS build! To do so, append .when(platforms: [.iOS])
to the dependency in Package.swift
, conditionalize your call sites using #if !os(Android)
, and either omit the functionality from your Android app or find an alternate Android solution.
You can read much more about both the advantages and the limitations of native Swift on Android in our full native Swift documentation. For the most part, though, relax and enjoy coding with the full power and expressiveness of Swift!
Testing
Due to limitations on build plugins, building the travel-posters-model
package in Xcode does not perform an Android build. It only builds for iOS. Rather, there are two simple ways to build for Android: use skip export
to create an Android library archive, which we explore later in this article, or run the unit tests.
Skip configures every native module with an extra unit test that builds the module for Android, transpiles your XCTests
to JUnit
tests, and runs them. Thus you’ll see two sets of results on every test run: first from XCTest
and then from JUnit
on Android. Frequently running your tests is a great way to catch both logic bugs and Android compilation errors early. Read more in the native testing documentation.
You must perform your tests against macOS - not an iOS simulator - for Skip to be able to build and run for Android.
The iOS App
Because our model is a standard SwiftPM package, you incorporate and use it on iOS like any other package. We briefly outline the steps we took to create and configure our sample iOS app below. Feel free to skip this section!
- Use Xcode to create a new Workspace in the
travelposters
directory alongside thetravel-posters-model
package. -
Use Xcode to create a new App project in the
travelposters/iOS
directory. Close the project after creating it, because we’re going to add it to our Workspace instead. - Add the
travel-posters-model
package to your Workspace. - Add the
iOS/TravelPosters/TravelPosters.xcodeproj
app to your Workspace. -
Add a package dependency from the app to the
travel-posters-model
local package.
You can now use your Xcode Workspace to iterate on both the shared model package and your iOS app. Browse the complete iOS TravelPosters
app here.
The Android App
We create our TravelPosters
Android app using Android Studio, starting with the “Empty Activity” template. Tell Android Studio to place the app in our travelposters/Android
folder.
Next, make Android/lib
, Android/lib/debug
, and Android/lib/release
directories. This is where we’ll place our compiled Swift model and Skip libraries.
We must also configure our project to use the new lib
directories. Edit the app
module’s build.gradle.kts
file to add these and other necessary dependencies:
...
dependencies {
...
implementation("org.jetbrains.kotlin:kotlin-reflect:2.1.0") // For reflection used by Skip
implementation("io.coil-kt:coil-compose:2.7.0") // For AsyncImage used to display posters
debugImplementation(fileTree(mapOf(
"dir" to "../lib/debug",
"include" to listOf("*.aar", "*.jar"),
"exclude" to listOf<String>()
)))
releaseImplementation(fileTree(mapOf(
"dir" to "../lib/release",
"include" to listOf("*.aar", "*.jar"),
"exclude" to listOf<String>()
)))
}
To prevent errors in the deployed app, include the following in build.gradle.kts
as well:
android {
packaging {
jniLibs {
// doNotStrip is needed to prevent errors like: java.lang.UnsatisfiedLinkError: dlopen failed: empty/missing DT_HASH/DT_GNU_HASH in "/data/app/…/base.apk!/lib/arm64-v8a/libdispatch.so" (new hash type from the future?) (see: https://github.com/finagolfin/swift-android-sdk/issues/67)
keepDebugSymbols.add("**/*.so")
}
}
}
Finally, our app needs internet access permissions to fetch weather and display remote images. Update its AndroidManifest.xml
file:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
...
</manifest>
Find the complete TravelPosters
Android app here. The next sections detail how to export our shared model to the Android app and how to use it from our Kotlin code.
Exporting the Shared Model to Android
We’ve configured our Android app to look in the Android/lib/debug
and Android/lib/release
folders for our model, but how do we populate these folders?
The skip export
command generates Android archives of a target Swift package and all of its dependencies. It has many options, which you can explore with skip export help
. The following Terminal command builds our travel-posters-model
and its dependencies for Android in debug mode and places the resulting .aar
library archives in the Android/lib/debug
directory:
skip export --project travel-posters-model -d Android/lib/debug/ --debug
To generate release archives instead:
skip export --project travel-posters-model -d Android/lib/release/ --release
Make sure to sync Android Studio whenever you update the libraries so that it picks up the latest changes.
Automation
There are many ways to automate this process, from simple scripting to git submodules to publishing the Android travel-posters-model
output to a local Maven repository. Use whatever system fits your team’s workflow best.
For example, to re-build and re-launch the app after making changes to the Swift code, you might run:
skip export --project travel-posters-model -d Android/lib/debug/ --debug
gradle -p Android installDebug
adb shell am start -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -n tools.skip.travelposters/tools.skip.travelposters.MainActivity
Using the Shared Model on Android
Now that we’ve set up the Android app to depend on our shared Swift model, what is it like to actually use the model in Kotlin and Compose code? The answer is that - thanks to SkipFuse bridging - it’s surprisingly natural!
Before we dive into using our model, though, we have to make a single call in our Android app’s main Activity
to initialize integration. Skip has extended Foundation.ProcessInfo
for this purpose:
// MainActivity.kt
...
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
skip.foundation.ProcessInfo.launch(context = this) // <-- INSERT
enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT))
setContent {
...
}
}
...
}
No additional changes to Android’s normal startup code path are needed.
This article is not a tutorial on using Jetpack Compose. Rather, we will focus on the places where our Android UI interacts with Swift, starting with the CityList
function for displaying the scrolling list of posters:
...
import travel.posters.model.CityManager // <-- 1
@Composable
fun CityList(...) {
val cityManager = CityManager.shared // <-- 2
LazyVerticalGrid(...) {
for (city in cityManager.allCities) { // <-- 3
item {
CityPoster(city, isFavorite = { cityManager.favoriteIDs.contains(city.id) }, setFavorite = { isFavorite ->
// 4
val favoriteIDs = cityManager.favoriteIDs.toMutableList()
if (isFavorite && !favoriteIDs.contains(city.id)) {
favoriteIDs.add(city.id)
cityManager.favoriteIDs = favoriteIDs
} else if (!isFavorite) {
favoriteIDs.remove(city.id)
cityManager.favoriteIDs = favoriteIDs
}
})
}
}
}
}
We’ve annotated the code above with four numbered comments. Let’s explain each:
- Our
TravelPostersModel
module is exposed to Kotlin in thetravel.posters.model
package. Skip simply divides your CamelCase Swift module names into “.”-separated Kotlin package names. Single-word packages are reserved in Kotlin, so if your module name consists of a single word, Skip appends “.module”. For example, moduleUtil
turns into Kotlin packageutil.module
. - Your Swift types and API have equivalent names and signatures in Kotlin.
- The Swift
CityManager.allCities
property of type[City]
bridges to a Kotlinkotlin.collections.List<City>
. Consult the bridging reference to learn more about specific type mappings. - Here we’re performing standard Compose state hoisting to manage the favorites list. Notice that we simply update our model - we do not explicitly trigger a change to the UI. Like SwiftUI, Compose automatically reacts to change in observed state, and SkipFuse ensures that our
@Observable CityManager
is fully and transparently integrated in Compose state tracking.
Each item in the city list is a CityPoster
. Let’s examine that function as well:
...
import travel.posters.model.City
import travel.posters.model.Weather
@Composable
fun CityPoster(city: City, isFavorite: () -> Boolean, setFavorite: (Boolean) -> Unit) {
Box {
val url = city.imageURL // <-- 1
AsyncImage(...)
...
Column(...) {
Row(...) {
...
Icon(imageVector = Icons.Filled.Star,
modifier = Modifier.clickable {
setFavorite(!isFavoriteState.value)
}
)
...
Text(text = city.name, ...)
...
Box {
...
LaunchedEffect(city.id, degrees) {
try { // <-- 2
val c = Weather.fetch( // <-- 3
latitude = city.latitude,
longitude = city.longitude
).conditions.temperature
...
} catch (exception: Exception) {
Log.e("TravelPosters", "Error fetching weather: $exception")
}
}
...
}
}
}
}
}
Once again, we’ve added numbered comments to points of interest in the code above:
- In addition to bridging your own types as well as built-in types like numbers, strings, arrays, and dictionaries, SkipFuse translates common Foundation types like
Data
,Date
,URL
, andUUID
to their Kotlin equivalents. In this case theCity.imageURL
property of typeFoundation.URL
maps to ajava.net.URI
. Again, see the bridging reference for details. - Our
Weather.fetch
Swift function is markedthrows
. If the native call produces an error, the bridged Kotlin call with throw a standard Kotlin exception. Weather.fetch
is anasync
Swift function. Skip therefore generates a Kotlinsuspend
function and integrates the call with Kotlin coroutines. Hence the use of aLaunchedEffect
in our Compose code.
As you can see, you invoke your Swift APIs naturally in Kotlin - almost exactly as if they were written in Kotlin themselves! Swift custom types, built-in types, and common Foundation types all translate to Kotlin/Java equivalents, thrown errors cause Kotlin exceptions, async Swift functions use Kotlin coroutines, etc. The goal is that using a module written in Swift should be almost indistinguishable from using a package written in Kotlin.
Browse the complete Android Compose code on GitHub.
Next Steps
If you haven’t already, check out Part 1 and especially Part 2 of this series.
If you’d like to learn much more about SkipFuse, bridging, and native Swift on Android, consider reading our Native Swift Tech Preview documentation.
You may also be interested in the nascent swift-java project, which is designed to facilitate communication between server-side Swift and Java libraries. While that is a very different environment than Android apps interacting with modern Kotlin APIs, they do overlap, and you might find swift-java's
bridging approach useful. We anticipate that as it matures, this bridge and Skip’s native bridging will begin to align more closely in their techniques and implementation details.
Coming Soon
Here are some of the topics we may explore in future parts of the native Swift on Android series:
A native Swift toolchain for Android.Your first native Swift Android app.Using a shared native Swift model to power separate SwiftUI iOS and Jetpack Compose Android apps.- Bridging Kotlin and Java API for consumption by native Swift.
- Incorporating native Swift, C, and C++ dependencies into your cross-platform Swift apps.
Conclusion
Many cross-platform solutions allow you to share code, but they typically come with serious downsides:
- Performance issues from the use of interpreters and/or complex runtimes (Javascript)
- High memory watermarks and unpredictable hitches caused by garbage collection (Javascript, Kotlin)
- Lack of transparent integration with SwiftUI and/or Compose state tracking (C/C++)
- Portability and memory safety concerns (C/C++)
Swift exhibits none of these problems. Its safety, efficiency, and expressiveness make it an ideal choice for cross-platform development. Swift is already a first-class citizen on Apple platforms, and Skip’s native tooling and technology ensures seamless integration with Android and Compose as well.
Whether you’re creating a single dual-platform app like we did in Part 1, separate iOS and Android apps with a shared model layer and bespoke interfaces like we did in this article, or anything in between, sharing code with Swift can save you significant time and effort when writing your app. More important than the up front savings, though, is the savings over time. A shared Swift codebase will eliminate endless hours of repeated bug fixes, enhancements, team coordination, and general maintenance over the life of your software.