Thoughts on Swift for Android

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 :sports_medal:: 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 .aars that you can then import from Android Studio. Before we do that, we use mvn deploy to make sure that the .aars 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!
  • 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 persisting
    • URLSession 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 and skip 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 and xcodebuild 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 .aars, 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!


  1. Some of these modules are UIKit based, some are SwiftUI based, depends on when they were written. ↩︎

  2. 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. ↩︎

29 Likes

Minsdk restriction and apk size are crucial

Thank you for the through write up! We've been using Swift on Android with the Swift Android SDK for several years now to share geometry libraries written in Swift. Your list of benefits and difficulties largely mirrors our own, particularly "one central place" and "debugging". We've experienced difficulty when one native, shared library needs to include another native, shared library (e.g. both are exposed to Kotlin, but also one depends on the other). Have you encountered this situation with your codebase yet?

We haven't been using the wonderful Skip tools as we need to share these libraries with C# and TypeScript clients in addition to Kotlin. We've had to build our own in-house tooling to do JNI interfacing with compiled Swift code. It is great to hear how much they are sharing with the community in the parts of the tool that make sense, as well as pushing the boundaries for UI work for paying customers.

5 Likes

Oh! It looks like the Swift Android SDK by @Finagolfin has been forked to a GitHub root URL now too. Great!

Thanks for the great writeup, @pcifani, clearly laying out the advantages and challenges of using the Android SDK and the Skip toolchain that builds on it. If you'd like to help move this Android effort forward, whether through contributing code or simply more feedback from your use, don't hesitate to reach out to the Android workgroup. :smiley:

If these are compiler assertions, they are probably there in Xcode too, just turned off.

It would be great to get some Cricut people on the Android channel of the Swift OSS Slack or on the Android workgroup, given your long use of this SDK. :smiling_face_with_sunglasses:

3 Likes

Why did you choose the Android→Kotlin transpiling option rather than running Swift directly on Android? Seems like doing so would resolve some of your issues

I’m assuming you are referring to Skip, rather than the Android SDK effort in general. It should be noted that Java/Kotlin integration has thus far been outside the scope of the Android workgroup’s efforts to date, but it is a frequently discussed topic and we anticipate that the Android workgroup will turning our attention to it once we are able to get an official SDK published (hopefully in time for 6.2 :crossed_fingers:).

As for Skip itself, our approach can best be summarized by our Native and Transpiled Modes documentation. The transpiled mode (“Skip Lite”) predates the compiled mode (“Skip Fuse”), but the two modes work together hand-in-hand in order to provide the bridge from compiled SwiftUI components to their Jetpack Compose equivalents in Kotlin, enabling you to create the equivalent user interface from a single SwiftUI codebase.

Ultimately, Android is a Java/Kotlin platform, and so no useful Swift app can be created without some mechanism for bridging back and forth between Android’s Java SDK and native code. Whether that bridging is done using Skip Fuse’s ability to embed Kotlin directly within your Swift code, or through one of the many other Java-Swift bridging projects that have evolved over the years, it is an essential components for going beyond a "Hello World" Swift sample and actually building a useful application.

1 Like

Thanks for the thoughtful reply - yes I’m familiar with Skip, but given the recent date of this post, I figured they may have had access to the “Native” mode during development. They mentioned challenges with Swift language features missing from Kotlin so I was interested to hear if they evaluated Skip Fuse.

Great to hear re: Android SDK Java/Kotlin interop efforts

@alex2 sorry for replying late:

I mention in the post:

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

This means that my code runs as native Swift on Android and Skip generates the JNI layer so it can be accessed from Kotlin code in Android studio

They mentioned challenges with Swift language features missing from Kotlin so I was interested to hear if they evaluated Skip Fuse.

When you use Skip Fuse, you can use all of Swift’s language features, but if that code is exposed to Kotlin, then it’s API might need some trimming, just like I mention with the `init() async throws` example.

1 Like

Thanks for clarifying!