KMMBridge and SKIE: SPM Development with KMP

· 9 min read

Author: Kevin Galligan
Kotlin Multiplatform tooling officially supports directly embedding Xcode Frameworks and using CocoaPods. However, the Apple developer ecosystem has made a rapid shift to Swift Package Manager. KMMBridge includes basic, but functional, support for SPM-based development. This entry in the KMMBridge and SKIE series will cover working with SPM.

Overview

Swift Package Manager (SPM) support will be critical for KMP adoption on native mobile. Currently, the Kotlin tooling does not have direct SPM support. KMMBridge has functional, although very simple, SPM support for local development.

Local vs Publishing

It’s important to distinguish between using a published package with SPM and locally compiling and testing Kotlin code with SPM. They are very different situations.

Published Binaries

The binaries published with KMMBridge are all in an XCFramework format. They can be integrated with either CocoaPods or SPM, and with some extra work, could be used with Carthage, or directly included. The actual XCFramework binary file is exactly the same.

Your Kotlin project that publishes the Xcode framework binary could use either Cocoapods or SPM for local development, and the published binary could be used with an SPM or Cocoapods client (or the other options mentioned above). How your local development is configured does not impact the remote, published binary’s options.

The important takeaway concept: the consumers of your published SDK can use SPM or CocoaPods, regardless of how you configure your source KOtlin project. The KMMBridge docs explain the details of this setup.

Local Development

“Local development” means you’re building the Kotlin binary on your machine from Kotlin source, and including it directly into Xcode rather than through some external publishing mechanism. If you are publishing an SDK, the only people who would need to worry about this are devs who need to debug or edit the Kotlin source.

For native mobile orgs sharing Kotlin just for their immediate team, an important design consideration for KMMBridge is the ability for team members to start with the published binaries, but also be able to easily run and build Kotlin locally as needed. This helps break down the “Language Silo”. If the consumers of your SDK can only use the published binary, this can create barriers to future shared code adoption.

CocoaPods

Local development with CocoaPods is one of the default options that ships with the Kotlin toolchain. In the KMMBridge context, the only thing to understand is how to turn it on and off. See the KMMBridge docs for more detail.

Swift Package Manager

Being able to locally develop with SPM is not complicated, but the integration can be difficult. CocoaPods, by comparison, is fairly straightforward. CocoaPods various scripts are all Ruby-based. In those scripts, you have access to whatever the Ruby runtime has access to. That allows you to insert local overriding configuration fairly easily.

SPM scripts are written in Swift. In theory, the situation would be simpler, but that is not the case. The sandbox in which SPM Swift scripts run dramatically restricts the information you have access to. As an example of how restrictive it can be, there is no way to get the current directory in which your project lives or in which that script runs. You can’t reliably pass in environment variables, etc. As a result, changing a project from including remove, published binaries to locally built ones is not trivial.

While we don’t know for sure, it’s assumed that the SPM scripts are so locked down to exclude side effects as much as possible. CocoaPods config can be much more flexible, but opens itself up to being much more brittle. Hopefully over time we can optimize the SPM dev experience more.

We have experimented with various ways to do this that are more user-friendly than the default method which will be explained here. However, nothing so far has been very useful and qualitatively less compromised than the method which SPM prescribes.

The basic process of locally developing Kotlin with SPM are described in the KMMBridge SPM docs, but we’ll step through the highlights here to help understand the issues.

Starting Scenario

We’ll assume you are publishing some Kotlin code as a binary and including it in the “main app” of some mobile project. The main app integrates versioned Xcode frameworks from the shared Kotlin project, but periodically there is a need to either debug a problem or develop a feature while being able to locally change and compile the Kotlin code.

In our case, the module is allshared. We aren’t using the test iOS app, but an external one that pulls the published builds into Xcode.

The test iOS app in our repo does use the local SPM flow, but it’s committed to the repo in that configuration. Essentially you never need to switch to “local mode” because it is always in local mode. You can, however, remove the local reference in Xcode and it will then attempt to get a published version.

Building Kotlin

The CocoaPods local dev flow includes run scripts in Xcode that let you change Kotlin code, run the build, and automatically get the new Kotlin included. For the local SPM dev flow, we have not yet figured out a simple and reliable way to do the same. That is on the longer term plan, but for now we need to run Gradle before building the final app in Xcode.

As discussed in the KMMBridge docs, we have a simple SPM dev build process. It’s a Gradle task, spmDevBuild, that builds and packages an XCFramework and Package.swift file for local SPM development.

That will “work” fine, but it will build every native target in your project that is configured to output an Xcode Framework. For local development, you only need the target for your local test simulator. That means, you’ll likely build at least 1, if not several, targets that you don’t need, and the native build and link time is by far the slowest part of most builds.

For that reason, we include another parameter to specify which target to build.

For Intel Macs:

./gradlew spmDevBuild -PspmBuildTargets=ios_x64

For Apple/ARM Mac (M1, M2, M3, etc):

./gradlew spmDevBuild -PspmBuildTargets=ios_simulator_arm64

When you edit Kotlin, run those statements before building the full app with Xcode.

In the sample repo we’ve included a shell script to automate this:

./ios-buildandrun.sh

It will build for Apple/ARM machines, then open Xcode for you. You can run this on the terminal, or create a Run Configuration in Android Studio or Intellij.

Using SPM Locally

This part of the integration is more confusing. The designers of Xcode and SPM have made assumptions about the use case for local development of a remote project, and provide a mechanism for doing so, but there are a few points that can easily derail the process.

SPM projects are focused around git repos. To avoid having a really tough time with SPM, it is highly recommended to have the core Package.swift file live in the root of the repo.

For “normal” Swift projects, this all works fine. The common use case is to have your Swift sources in the repo, so when you include the project remotely, you’re simply building those sources. SPM grabs the sources, builds them, and includes them. For local development, the paths are the same.

For Kotlin builds, we need to publish binaries. SPM can consume remote binaries. However, it is an either/or situation. The main Package.swift file either points at a local binary, or a remote URL. It can’t use a different one in different contexts. For Swift projects, this is only an issue when an SDK is being published and having the dev using the library building the Swift code. The most common scenario is if somebody is publishing a Swift library and does not want the source to be available. For internal libraries, generally speaking, that isn’t an issue. For open source, obviously that’s not a problem. The cases where this is an issue are largely around proprietary SDKs, and while they’re not unheard of, in theory they are less common.

Because we don’t want to force devs to build Kotlin locally, the “uncommon” use case is now the default, and we have to adjust to it.

You can follow the detailed instructions for the local SPM dev flow in the KMMBridge docs, but we’ll do a quick overview here to round out the discussion.

To be clear: you should only need to follow these steps if you want to build and test Kotlin code within the final application. Generally speaking, if you are using this kind of SDK publishing flow, the day-to-day development and testing should be happening in a more dedicated setup.

However, being able to build and test directly in the final app is very useful.

The basic process is as follows:

  • Clone the Kotlin source repo (if you don’t already have it).
  • Important leave the name of the repo’s folder the same! This is a strange constraint imposed by Xcode and/or SPM, but we’ll explain in a bit.
  • Run the local dev flow build statement above.
  • Using Finder, drag the parent folder of the Kotlin project into Xcode, just under the iOS project in the tree. This is the weirdest part, but it works. Xcode will recognize that this project is a local build of the remote binary you were including, and use it instead of the remote one.
  • Assuming everything is configured correctly, you should be able to build the iOS app and run it with the local Kotlin binary.

When you are done testing and editing Kotlin, you should remove the folder reference from Xcode. However, you’ll also need to publish a new version to match the changes before those changes will appear in the client app. It’ll need to update its version to that new version. That delayed dev cycle is one of the issues introduced by the SDK/publish dev flow.

Summary

The local SPM developer experience is less polished than the other options available from the Kotlin tooling, but is critical to the long term success of KMP. However, the local SPM dev flow is not required to publish SDK’s that can be consumed with SPM. The ideal configuration for your team will depend on how your team prefers to work, but KMP can support any necessary configuration.