Intro
We have our own analytics library that originally lived on iOS - our entire stack was Swift. When we built our native Android app, instead of reimplementing the analytics we decided to just cross-compile the existing Swift library for Android.
What follows is a list of the problems I ran into while doing this, along with the solutions I found - so that anyone attempting the same thing later can look at it and reuse what's useful. Here is a sample repo of my setup swift-lib-on-android-sample
Setup
For the toolchain, I used Swiftly and @finagolfin's Swift Android SDK - a cross-compilation SDK bundle providing prebuilt Android SDKs for AArch64, armv7, and x86_64. I started this whole endeavor before the Swift on Android Working Group was even announced.
A remark on this: that was the original setup. After the second or third release, I switched over to the official Swift SDK for Android.
Problem 0: Getting the .so Files into Android
Once I managed to cross-compile my library, the next challenge was: it depends on dynamic libraries from the Swift SDK, and Android needs all of them present at runtime. I had to find every transitive .so dependency, locate it inside the SDK, and copy it into my Android project.
To solve this, I wrote a small CLI tool: you point it at your Swift package, it builds it, then walks the dependency tree of the resulting .so files - iteratively resolving each dependency, finding it in the SDK, and collecting everything into a single output directory ready to drop into Android.
After a couple of iterations, this became a partly automated build step that I plug into my pipeline. You can check it and re-use: swift-android-bundler.
Problem 1: Main Thread
This was a completely unexpected issue. On native Apple platforms (iOS, macOS), the main thread has a run loop started by the application itself. When you port Swift code to Android, there is no such run loop for Swift - Android has its own run loop, but nothing is draining Swift's main dispatch queue, which means anything dispatched to the main queue simply never executes.
I spent some time debugging why my code would just hang, until I realized that some calls were hopping to the main thread and getting stuck there forever.
After digging through the Swift Forums, I found the solution: libdispatch exposes an internal function, _dispatch_main_queue_callback_4CF, that drains the main queue. My current workaround is a high-frequency timer (every ~1ms) that calls this function on the main thread - if there's anything queued, it gets drained.
In the end, this works for my case because my code has no direct user actions and reactions to them. So the workaround holds for now, but it would be good to come up with something better.
You can see how I wire this up here: SwiftRuntime.kt.
Problem 2: Binary Size
After getting everything running, I looked at the total size of all the .so files I needed to bundle: 120 MB. I knew this topic had come up on the forums before, but I had no real idea what I'd run into when I actually started doing it.
The SDK dependencies fell into two major chunks:
Foundation + ICU (~40+ MB). Full Foundation pulls in ICU for internationalization and localization. On iOS this ships with the OS - on Android you have to bundle it yourself.
Foundation Networking (~10+ MB). Our networking layer was built on FoundationNetworking, which pulls in OpenSSL and its other dependencies.
I used my dependency tool to trace exactly which parts of my code actually depended on ICU, full Foundation, and Foundation Networking. It turned out that these dependencies, while real, were replaceable and not actually required. The tool also has a mode that strips the Foundation and Foundation Networking dependencies from the bundle.
I made two key changes:
-
Eliminated unnecessary dependencies on full Foundation/ICU in my analytics code.
-
Replaced
FoundationNetworkingentirely. I already had a separate networking package, so I added an Android-specific target that uses Android's native networking APIs. This let me dropFoundationNetworkingand its dependencies completely.
A few examples of Foundation APIs I had to replace
This is not a full list - just some examples of functions that were scattered all over my code and that I had to get rid of one way or another:
-
String(format:)- a wrapper over NSString; replaced with pure-Swift padding/decimal helpers. -
error.localizedDescription- bridges through NSError (absent on Android); wrapped in#if canImport(Darwin)with a"\(self)"fallback. -
String.contains(_ other: String)- Foundation (uses NSString.range(of:)); replaced with Regex:.contains(/SomeException/). -
String(cString:encoding: .utf8)- Foundation; replaced with stdlib:String(validatingCString: ptr). -
Bundle.main,Thread.isMainThread,NotificationCenter- don't exist on Android; wrapped in#if os(iOS)or replaced with protocol abstractions.
URLRequest was especially widely used and couldn't simply be dropped. I put together a sample repo showing roughly the networking implementation I use - a package that redirects to either FoundationNetworking or an Android implementation depending on the platform: NetworkKit.
Problem 3: The Linker Keeps Pulling in Full Foundation
You'd be surprised how many transitive dependencies a simple analytics library has. Whatever I did, the linker would still record full Foundation as a dependency in the binary - even after I thought I'd combed through every usage.
I didn't want to keep cleaning everything up by hand constantly - it's far too tedious. So for the full-Foundation problem, I just decided to strip it manually.
Iterative trimming & results
My approach was iterative: first, get everything working with all dependencies copied and nothing stripped. Once I confirmed it runs - start trimming: remove unnecessary dependencies, strip symbols, let the tool validate.
Results: down from 120 MB to 27 MB. Of that, 9.3 MB is my analytics library and 2.1 MB is libSwiftJava.so; the rest is the minimal set of Swift runtime and SDK libraries actually needed.
Problem 4: Library Loading Order
On API 23+, Android resolves transitive .so dependencies automatically - you don't need to load them manually in a specific order. But there's one important exception: libdispatch.so must be loaded on the main thread, because it treats whatever thread loads it as Swift's main thread. If it's loaded on a background thread, DispatchQueue.main and @MainActorpoint to the wrong thread.
My solution: load everything on a background I/O thread, except libdispatch.so - hop to the main thread for that one, then hop back. The reason for this somewhat odd approach is that loading 27 MB on the main thread isn't something every Android device handles well - on some it can cause serious hangs, possibly even an ANR. If you're only loading one or two libraries, doing it on the main thread is more or less acceptable and the stalls aren't as bad. But with this much to load, it has to go off the main thread.
This all happens behind the splash screen while a short video plays, so by the time the user enters the app, everything is initialized and ready.
Problem 5: Crash Symbolication (Firebase Crashlytics)
After stripping debug symbols from the .so files, you still need those symbols to symbolicate crashes in Firebase Crashlytics.
My tool copies all the untouched (un-stripped) files into a separate folder and creates a readme there. You just read the readme, take the commands from it, plug in your key, and run them to upload the symbols.
Problem 6: Tests on the Emulator
When I have time, I'll write this one up as a separate problem. In short: running XCTest bundles on an Android emulator was a challenge of its own, and I only got it working by bumping the minimum API level up to 29.
Current State
The app with the shared Swift analytics library is now shipped and running in production on Android. Analytics events arrive on the server correctly.
A big shoutout to the swift-java project - it significantly reduced the JNI bridging boilerplate.
One thing I now run in CI is a check that I can safely strip full Foundation and Foundation Networking. It's surprisingly easy to pick up a dependency on full Foundation while you're focused only on iOS. It's bitten me a couple of times already - for example, I used NSRecursiveLock without a second thought, and it later got flagged as something that drags in full Foundation. The CI check catches this before it ships.