Thoughts on Swift for Android
I wanted to take the opportunity to share how in my company we are using Swift to power our Android apps in order to be more competitive in the current market. We’ll show you what issues we’ve faced and how we managed to solve them. All of our experience with Swift on Android has been using Skip’s Toolchain.
Context
I work in a small Software Studio, specialized in building Native Apps. Our main clients have worked with us for years, where we have complex codebases that power apps that thousands of users use every day.
The codebases of our iOS apps are heavily modularized, with one Module (which we call “Core”) powering the main business logic, and a Module for each screen[1]. The Package structure looks like this:
graph LR
A[Core] --> B[CoreUI]
B --> C[FeatureA]
B --> D[FeatureB]
B --> E[FeatureC]
C --> F[App]
D --> F
E--> F
This has proven to be a great way to build apps since boundaries are set at compile time and we, the engineers, have to respect them. SPM has made this a breeze to setup and maintain.
Over the last year, we had a lot of turnover in our Android Team, leaving us shorthanded. This meant that we started struggling to ship features with the same speed and reliability that we are used to on the iOS team.
This is when we discovered Skip and their effort to make Swift on Android a reality. So we quickly began the process of experimenting the amount of work we’d have to do to make sure we can reuse parts of our iOS codebases in their Android counterpart.
This post will focus on Skip’s offering of exporting Swift Packages as .aar
libraries that can then be imported in Android Studio to power Android apps. They also offer a solution to make fully functional Android apps in SwiftUI, but this is something we still have no "production" experience with.
Process
We set the goal to share the Core package with the Android apps and gradually adopting it in their codebase, layer by layer, in order to reuse the code written for the iOS app while deleting the Java/Kotlin equivalents we had there.
As I mentioned, when we started this process we already had a healthy iOS codebase, modularized with Swift Package Manager, fully Swift 6 compatible and only used Swift Concurrency (no GCD or Combine). This meant that we already had a great foundation to start working on.
So, we started to work. To start getting our hands dirty, we tried something simpler than building for Android: building our Core package for macOS. Immediately we found some UIKit & SwiftUI code where we didn’t expect, so we started moving those to other places.
Thankfully, most of this code was extensions to models vended by the Core Module in order to provide affordances to the UI layer, so it was easy to drop it somewhere else. Other places, where we used stuff like UIDevice
to make decisions, we parametrized those.
Some dependencies of your Core Module might not be ready for the Mac so it’s already something that you need to either remove them, or fork them and fix them. This process will become handy when you start the Android port.
Pro Tip
: I advise fellow developers to first start porting their Packages to the Mac because there you have Xcode support, whereas when porting to Android, most of it happens in the Terminal.
Then, you install Skip’s Android Toolchain and run skip android build
in the Terminal. This will first build for Android the dependencies of your Module. For better results, make sure that the code you’re porting only depends on Foundation. This means that it’ll be quite simple to port to Android, given that mostly it’ll be adjusting a few imports. We did this process for one of our Open Source packages, you can see the Pull Request here.
Once skip android build
passes, it is a good idea to add this as a Step in your CI pipeline, in order to keep the project in check. Or even better: run skip android test
and when those pass, and also add it as a step in your Pipeline.
After this is done, you then add the Skip plugin to your Module, along with a skip.yml
file in order for Skip to start learning about what parts of your Module it has to create Kotlin bindings for.
Fun Fact💡: One thing is for Swift packages to compile on Android, another story is for the methods in those Packages to be visible from the Android codebase. For that, JNI interaction is required in order to bridge from Swift to Kotlin and vice versa.
Skip’s plugin will tell you which methods you won’t be able to bridge to Kotlin because they use language features that can’t be bridged. You then have to either opt out of bridging those, or modify the iOS codebase.
One example of this is that Kotlin doesn't have an equivalent to init() async throws {}
. So in order to have a proper code path for Android, I ended up doing:
class Foo {
// SKIP @nobridge
public init() async throws { … }
#if os(Android)
public static func create() async throws {
try await Self.init()
}
#endif
}
Android clients will invoke Foo.create()
while iOS clients will still call try await Foo()
. It is not uncommon to bend the rules like this in order to keep moving. As long as the Android codepath ends up calling the iOS codepath, you won't be maintaining two different codepaths for the same functionality; you'll just have a "worse" API on Android, but still the same implementation.
Once all plugin warnings and errors have been addressed, it is time to move back to the Terminal and now and run skip export --module Core
.
We did run into a lot of issues in this step, mainly with Nested Types that declared a custom Codable
conformance. Skip's export
step needed a canonical init
to generate the Kotlin bindings for these types. This is the step that impacted the most our codebase, but we managed to get it done thanks to the help of the folks at Skip.
After you manage to fix all the issues, you'll have the .aar
s that you can then import from Android Studio. Before we do that, we use mvn deploy
to make sure that the .aar
s are vended as a "Local Repository" so Gradle in Android Studio will be able to see these dependencies. We store everything generated in this file in Git LFS in order to improve the developer experience with Source Control.
Now, it's up to you and the Android team to decide how to best integrate the Swift Package code into your project. Our strategy has been to start with the simplest of functionality, and keep iterating from there.
Result
We currently have three apps in the Play Store that include Swift, and are working on the fourth. As we mentioned earlier, we are including the "Core" Module in them and slowly replacing all the layers powered by Java or Kotlin to the systems vended by the Swift Packages.
During this time we even figured out how to make an Open Source Swift Package available for Android, so you can "just"[2] import from your build.gradle.kts
.
I'll briefly discuss the takeaways of this process:
Benefits
- One central place to write all the business logic and networking
- Improved team work by breaking down silos.
- Swift is a vastly superior language than Kotlin.
- Kotlin not marking throwing functions is definitely something that takes A LOT to get used to.
- Influence the Android architecture, leading to a coherent architecture across both platforms.
- Our clients did not even notice we were working on this, they just noticed we were shipping faster as a team.
- Thanks to SkipModel, the
@Observable
objects can be shared to power Compose views. - Better code structure in order to work around the limitation of sharing code with a foreign platform.
- You can’t just
import CoreLocation
anywhere in your codebase anymore!
- You can’t just
- The Skip team is amazing, they worked with us on it and it’s currently working flawlessly.
Difficulties
- Getting the Android folks on board has been a challenge.
- Fitting our Swift code into Hilt or the ViewModel pattern has been by far the biggest challenge.
- Debugging inside the Swift libraries is only possible via logs.
- Platform differences or gaps like:
UserDefaults
not persistingURLSession
crashes sometimes on deinit
- Android app’s binary size has increased 40MB at download time.
- So far, this hasn't translated into less User Acquisition according to the Play Store.
- Adjusting to non-deterministic life cycle of our Swift objects created from Kotlin/Java
@MainActor
is not enforced at compile time.- Both
skip android build
andskip export
run in the Terminal and when starting to port a complex codebase, it can be exhausting to review every error without proper IDE support. - The fact that
swift build
andxcodebuild
use different toolchains means that you could find compiler bugs in the Open Source version that are not there in the Xcode version.- We saw this in one of our Core modules: the Swift compiler just crashes when running in Release mode, ergo we're shipping that app to production with the "debug" flavour of the Core Module.
- We had to increase the
minSdkVersion
from 24 to 29, which is the minimum supported version for Skip's Toolchain.- I'd argue that this is the only place that raised a few eyebrows from our clients.
- JNI bindings are produced by Skip and has definitely been an area where we pushed what the tool was capable of doing.
Recommendations
One thing I can recommend to fellow developers that will embark on this journey is the same thing I recommend when approaching big refactors: always be shipping. All of the changes that we have had to do in our iOS codebase were merged as frequently as possible and shipped to end users long before we had any Swift code running on Android.
Unless you do this, you'll have long lived branches that are hard to keep in sync with the ongoing work. Plus; most of the changes you make to add Android support will lead to a better codebase, with less coupling and better dependency injection. So your codebase will get healthier during this process.
Future
We are committed to this path and have bet our company’s mid to long term survival to this. Economic pressures to deliver cross platform solutions have been too big to ignore and this will allow us a way out while keeping our excellence levels on Apple platforms.
We'd love to see a streamlined Developer Experience where a Package.swift
can be dropped alongside a build.gradle
file:
FooApp/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
├── gradlew
├── gradlew.bat
├── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── main/...
│ └── test/...
└── core/
├── build.gradle.kts
├── Package.swift
├── Sources/*.swift
└── Tests/*.swift
The invocation of the Swift Toolchain would be handled by a Gradle plugin and done automatically as part of the Gradle build process. This will allow us to drop our custom scripts that export the .aar
s, publish them in a local Maven repository and store them in Git LFS. Ideally this would even allow us to have one single git repository for both Apps, further eliminating the "silos" between the two teams.
I think that this solution would also allow better debugging inside the Swift codebase, and if Android Studio is not flexible enough for this, I'm sure there's a way to build this inside VSCode.
Thanks a lot for your time and cheers to the Swift's Android Workgroup!
Some of these modules are UIKit based, some are SwiftUI based, depends on when they were written. ↩︎
I'm being ironic here with the use of "just", because nothing is easy in Android. You can see the differences between the Android and Apple Platforms in the README. ↩︎