Running a Shared Swift Library on Android

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 FoundationNetworking entirely. I already had a separate networking package, so I added an Android-specific target that uses Android's native networking APIs. This let me drop FoundationNetworking and 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:

  1. String(format:) - a wrapper over NSString; replaced with pure-Swift padding/decimal helpers.

  2. error.localizedDescription - bridges through NSError (absent on Android); wrapped in #if canImport(Darwin) with a "\(self)"fallback.

  3. String.contains(_ other: String) - Foundation (uses NSString.range(of:)); replaced with Regex: .contains(/SomeException/).

  4. String(cString:encoding: .utf8) - Foundation; replaced with stdlib: String(validatingCString: ptr).

  5. 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.

6 Likes

Thanks for posting this. I’m heading down the same road myself, and bookmarking advice like this.

I do hope the tooling will improve to address these issues, both the runtime problems and the excessive binary size.

Fantastic writeup!

A few random notes and observations:

Swift Java Fork

I notice at swift-lib-on-android-sample/Toolkit/NetworkKit/Package.swift at main · NikolayJuly/swift-lib-on-android-sample · GitHub :

// Minimal fork of swiftlang/swift-java — strips heavy targets that require JAVA_HOME at build time.
// Full rationale in the fork's README.
.package(url: "https://github.com/NikolayJuly/swift-java.git", branch: "minimal"),

This should no longer be necessary: JAVA_HOME should no longer be required as of Dynamically load libjvm rather than requiring it at build time by marcprux · Pull Request #8 · swiftlang/swift-java-jni-core · GitHub. You may have other reasons to include your fork, but it's always nice to use the authoritative upstream if possible, where you can file issue reports (and PRs!) when you encounter shortcomings.

Identifying transitive dependencies

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.

Yes, this is indeed a hassle. I've been advocating for some official Android bundling tool, either as a top-level swiftlang project, or even ideally as part of SwiftPM itself.

For the time being, though, you might find using ELFKit simpler and more robust than spawning patchelf. We use that in the skip android … commands to peek into the .so files and see who depends on what when determining the dependencies for packaging.

Looper / Main Thread Integration

I found the solution: libdispatch exposes an internal function, _dispatch_main_queue_callback_4CF , that drains the main queue.

The symbol is there; you just need to expose it. We work around that in the AndroidLooper module in GitHub - swift-android-sdk/swift-android-native: Access to the Android NDK from Swift · GitHub. I highly recommend looking at that package for general Android integration work, since it covers a lot of bases and is used in production in a number of apps.

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.

Eeks! You certainly shouldn't need to do that.

If it's loaded on a background thread, DispatchQueue.main and @MainActor point to the wrong thread.

Yes, that is indeed a footgun. However, I'm surprised that anything works if you don't bootstrap the libraries on the main thread. I think there is potential for more breakage than just the dispatch queues.

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.

I've never experienced that. As long as you aren't doing a lot of other work, the mere loading of the .so files should be nearly instantaneous. Are you compressing the .so files (using "legacy" packaging), which necessitates the unpacking of the files at runtime (or install time)? That could be the culprit.

Thanks for taking the time to write this up. It is of keen interest to us on the Android workgroup. If you would be interested in chatting more about it, please consider joining an upcoming Android workgroup meeting. And, of course, issue reports and contributions to the various swiftlang packages (like swift-java, swift-java-jni-core) and satellite packages (like swift-android-native) would be greatly appreciated!

1 Like

@marcprux Thank you for your reply and for many valid points.

Thank you for the advice. Will take a look.

Yes, for now I'm using legacy one. I will play later with optimizing things. For now I just wanted to make it running and check how it effect app performace and crash rate as a base line.

I already know that the Foundation and Dispatch folks will tell you that using this is a bad idea. It's part of a private interface between Core Foundation and Dispatch. A much safer alternative would be to spin the CFRunLoop using CFRunLoopRunInMode(.defaultMode, 0, false). That should cause Dispatch to process its main queue and will also allow anything sent to the main CFRunLoop to execute as well.

Additionally, a better way to do this than running it on a high frequency timer would be to call it from a MessageQueue.IdleHandler; that way, you've effectively tied CFRunLoop into your Looper — it will run round the CFRunLoop once every time it goes around the Looper's main loop.

2 Likes