The SwiftUI client in the PeopleInSpace Kotlin Multiplatform sample has, up to now, consumed the shared Kotlin code through a framework with a generated Objective-C API, along with SKIE to make that code nicer to use from Swift (for things like sealed classes and flows). Kotlin also has Swift export (currently in alpha) which generates Swift bindings directly, and in this article we’re going to go through what was needed to move PeopleInSpace over to it. The changes are in this PR.

Note that this is using a Kotlin 2.5.0-Beta2 dev build (2.5.0-Beta2-58, from https://packages.jetbrains.team/maven/p/kt/dev). Earlier versions either failed to build or crashed at runtime for this project, and Beta2 also includes some DSL changes that the setup below relies on.


Gradle setup

We firstly removed the SKIE and multiplatform-swiftpackage plugins from common/build.gradle.kts, along with the binaries.framework block that was building the Objective-C framework. The Swift export configuration was then added to a new ios-export module. This is a small “umbrella” module that just depends on common (it has no code of its own), and we’ll come back to why it’s needed later.

ios-export/build.gradle.kts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
kotlin {
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            api(projects.common)
        }
    }

    export {
        swift {
            moduleName = "Shared"
            xcodeIntegration {
                configure(projects.common) {
                    moduleName = "Common"
                    rootPackage = "dev.johnoreilly.common"
                }
                configure("io.insert-koin:koin-core", SwiftExportVisibility.HIDDEN)
                configure("org.jetbrains.kotlinx:kotlinx-serialization-core", SwiftExportVisibility.HIDDEN)
                configure("androidx.lifecycle:lifecycle-viewmodel", SwiftExportVisibility.HIDDEN)
            }
        }
    }
}

export { swift { } } is the new Swift export DSL in Kotlin 2.5 (the swiftExport { } block used previously is now deprecated). The configure() calls let us set options for each dependency. For common we’re setting the module name that we import in Swift, and also rootPackage, which means we can use for example di.initKoin() in Swift rather than ExportedKotlinPackages.dev.johnoreilly.common.di.initKoin(). The other three configure() calls mark the Koin, kotlinx.serialization and lifecycle ViewModel libraries as HIDDEN, which keeps them mostly out of the generated Swift.

There were also a few changes needed in the Xcode project. The Run Script build phase now calls ./gradlew :ios-export:embedSwiftExportForXcode (instead of :common:embedAndSignAppleFrameworkForXcode), and the deployment target had to be updated to iOS 18 as the coroutines support code that Swift export includes makes use of Mutex. We also had to add -lsqlite3 to the linker flags (more on that later).


Controlling what’s exported

Most of the work involved here was in reducing how much of the Kotlin code was exported to Swift. The first version exported around 18k lines of Swift, covering Ktor, SQLDelight, Koin, Kermit, kotlinx.serialization and others, along with our own code. As well as slowing down the build, a lot of that was code that Swift never used, and the generated code for some of it (mostly around generics) also didn’t compile. With the changes below that’s now down to around 2.6k lines.

What gets exported for a dependency depends on how it’s included. If it’s an api dependency of the module being exported then it’s exported in full (every public declaration). Otherwise it’s only exported “transitively”, which means just the parts that are actually reachable from the exported API. Marking a dependency as HIDDEN reduces it further, to stubs for just the types our API references. The generated Swift for Koin, for example, went from around 2,000 lines to 22. The Koin API also references a lot of Kotlin standard library types, so the generated stdlib code went from around 1,700 lines to under 200 as well.

This is why we needed the ios-export module. If the export block is in common instead then its own api dependencies (Koin, Kermit etc) get exported in full, which also pulls in all of kotlinx-coroutines-core. That added around 17k lines of Swift and the generated code didn’t compile. We also can’t hide coroutines, as the Swift code needs StateFlow. With the umbrella module, common is what gets exported in full and its dependencies are only exported transitively (there isn’t currently a way to request that using the DSL).

That still leaves common itself, where every public declaration is exported whether it’s used by Swift or not (along with any library types they reference). To help with that we enabled explicit API mode, which fails the build if a public declaration doesn’t specify its visibility.

common/build.gradle.kts
1
2
3
4
kotlin {
    explicitApi()
    ...
}

That flagged 53 declarations, and for each one we then decided whether it should be public or internal. Things like the repository implementation, the Ktor HttpClient setup, the Koin modules and the API class constructors are now internal, with other code using PeopleInSpaceRepositoryInterface and getting the rest through Koin.

The SQLDelight generated database classes are also public, so those were moved to a new db module. common includes that using implementation and only uses it from internal code, so SQLDelight is no longer part of the export. The SQLDelight plugin was what had been adding the -lsqlite3 linker flag though, and it’s now only applied to the db module, which is why that flag had to be added explicitly to the Xcode project (and also for the Windows DLL that’s built from common).

Note that internal applies to the whole module, so all of the other clients also now see a smaller public API from common. That’s also useful in its own right, as it makes it clearer which parts of common the other clients are meant to use. And as I found in an earlier post on iOS build times, reducing the size of the exported API also makes a big difference to Swift export build times.


Swift changes

In the Swift code import common became import Common, and the Kotlin packages are now Swift namespaces, so for example KoinKt.personListViewModel() is now di.personListViewModel() and Assignment is now remote.Assignment.

To give an idea of what the generated bindings look like, this is our PersonListViewModel class.

PersonListViewModel.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
public class PersonListViewModel internal constructor(
    private val peopleInSpaceRepository: PeopleInSpaceRepositoryInterface
) : ViewModel() {

    public val uiState: StateFlow<PersonListUiState> = peopleInSpaceRepository.personListUiState()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState.Loading)

    public fun refresh() {
        viewModelScope.launch {
            peopleInSpaceRepository.fetchAndStorePeople()
        }
    }
}

And this is the Swift that’s generated for it (the body of the uiState getter is left out as it’s a long line of bridging code).

Common.swift (generated)
1
2
3
4
5
6
7
8
9
10
11
12
extension ExportedKotlinPackages.dev.johnoreilly.common.viewmodel {
    public final class PersonListViewModel: ExportedKotlinPackages.androidx.lifecycle.ViewModel {
        public var uiState: any KotlinCoroutineSupport.KotlinTypedStateFlow<ExportedKotlinPackages.dev.johnoreilly.common.viewmodel.PersonListUiState> {
            get {
                ...
            }
        }
        public func refresh() -> Swift.Void {
            return { dev_johnoreilly_common_viewmodel_PersonListViewModel_refresh(self.__externalRCRef()); return () }()
        }
    }
}

We had been using SKIE’s Observing SwiftUI view to observe the view model’s StateFlows. As shown above, the StateFlow property is exported as KotlinTypedStateFlow<T>, which has a value and an asAsyncSequence() function, so we added our own version of that.

Observing.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct Observing<Value, Content: View>: View {
    private let flow: any KotlinTypedStateFlow<Value>
    private let content: (Value) -> Content

    @State private var value: Value

    init(
        _ flow: any KotlinTypedStateFlow<Value>,
        @ViewBuilder content: @escaping (Value) -> Content
    ) {
        self.flow = flow
        self.content = content
        self._value = State(initialValue: flow.value)
    }

    var body: some View {
        content(value)
            .task {
                do {
                    for try await next in flow.asAsyncSequence() {
                        value = next
                    }
                } catch {}
            }
    }
}

We were also using SKIE’s onEnum(of:) to switch over the sealed PersonListUiState class. Swift export generates a sealedType() function for sealed classes which returns a Swift enum, so we can do the same thing with that.

ContentView.swift
1
2
3
4
5
6
7
8
9
10
Observing(viewModel.uiState) { uiState in
    switch uiState.sealedType() {
    case .success(let success):
        // use success.value.result
    case .error(let failure):
        // use failure.value.message
    case .loading:
        ...
    }
}

The ISS position screen is a Compose for iOS screen that shows a native SwiftUI map view. It does that using a Kotlin NativeViewFactory interface that we implement in Swift. Kotlin interfaces are exported as Swift protocols that require the class implementing them to extend KotlinBase, so that’s the main change needed there.

NativeViewFactory.swift
1
2
3
4
5
6
7
8
class iOSNativeViewFactory : KotlinBase, ui.NativeViewFactory {
    static var shared = iOSNativeViewFactory()

    func createISSMapView(viewModel: viewmodel.ISSPositionViewModel) -> UIViewController {
        let mapView = NativeISSMapView(viewModel: viewModel)
        return UIHostingController(rootView: mapView)
    }
}

One issue we hit was that if a function returns a type from a HIDDEN dependency then the generated Swift function just calls fatalError(), and there’s no warning or error when building. Our initKoin() function returned Koin’s KoinApplication and the app crashed on startup as a result. To fix that, the version of initKoin() that’s called from Swift now returns Unit.


Summary

The changes needed on the Swift side ended up being fairly small, and the generated API works well from Swift (with namespaces, enums for sealed classes and typed flows). The main work was getting the exported API down to just what Swift needs, which is worth doing whether you’re using Swift export or the Objective-C based framework.

Swift export is still in alpha and is changing quickly between Kotlin versions. The PR is still a draft for now and the plan is to update it to use 2.5.0-Beta2 once that’s available.