KMP and Swift Interop
ObjC bridge, suspend and Flow to Swift, SKIE, XCFramework, SPM, linking, Swift export
- 01
An iOS engineer switches over your sealed
AuthStateand Xcode still demands adefaultbranch — why?MediumBecause the framework header is Objective-C, and Objective-C has no closed hierarchies — a sealed type arrives as an ordinary open class with subclasses, so Swift has no way to know the list is complete.
// commonMain sealed interface AuthState { data object Anonymous : AuthState data class LoggedIn(val token: String) : AuthState data class Failed(val reason: String) : AuthState } … - 02
Your shared
Result<T>reaches Swift with every success value typedAny— what does the Objective-C bridge do to Kotlin generics?MediumObjective-C has lightweight generics on classes and nothing else, so Kotlin's class-level type parameters survive as compile-time hints and every other use of a generic degrades to
Any.// commonMain // ✅ A generic CLASS crosses: Swift sees Page<Post> class Page<T>(val items: List<T>, val nextCursor: String?) // ❌ A generic INTERFACE does not — ObjC protocols have no type parameters, … - 03
Why must the iOS team pass every argument to a Kotlin function that has defaults, and where do Swift names like
doCopy()come from?MediumObjective-C has neither default arguments nor overloading, so the header generator drops the defaults outright and mangles any name that would collide.
@file:OptIn(ExperimentalObjCName::class, ExperimentalObjCRefinement::class) // ❌ Defaults evaporate — Swift must write search(query:limit:offset:) class SearchApi { suspend fun search(query: String, limit: Int = 20, offset: Int = 0): List<Post> = TODO() } … - 04
An iOS screen is dismissed and Swift cancels its
Task, but your sharedsuspend funkeeps downloading — why doesn't that cancellation reach Kotlin?MediumBecause the generated bridge is a one-way completion handler: Swift hands Kotlin a block and gets back nothing that could carry a cancel.
// ❌ Exported directly: Swift gets `try await`, Kotlin never hears the cancel class UserApi(private val client: HttpClient) { suspend fun fetchUser(id: Long): User = client.get("/users/$id").body() } // Generated header: // - (void)fetchUserId:(int64_t)id … - 05
Your shared repository exposes
Flow<List<Post>>and the iOS team says it is unusable from Swift — what are their options?MediumA raw
Flowcrosses as an opaque Objective-C class whose only member iscollect(collector:completionHandler:), andFlowCollectoris a generic interface — so Swift would have to implement a protocol just to receiveAny?values one at a time.// commonMain // ❌ Raw export: Swift sees an opaque Flow with collect(collector:completionHandler:) class PostRepo(private val dao: PostDao) { fun posts(): Flow<List<Post>> = dao.observePosts() } … - 06
The iOS lead does not want another compiler plugin in the build — what does SKIE actually generate, and what does it cost?
MediumSKIE is a Kotlin compiler plugin from Touchlab that generates a Swift layer on top of the Objective-C framework and ships it inside the same framework, so the iOS project installs nothing and imports nothing new.
// shared/build.gradle.kts plugins { kotlin("multiplatform") alias(libs.plugins.skie) // co.touchlab.skie — pin it to your Kotlin version } … - 07
A network timeout inside a shared call kills the iOS app outright even though Swift wrapped it in
do/catch— what is going on?MediumOnly exceptions listed in
@Throwsare converted toNSError; every other Kotlin exception reaching Objective-C is treated as unhandled and terminates the process, and no Swiftcatchcan intercept it.// ❌ Nothing is annotated: a timeout reaching ObjC terminates the process. // Swift's do/catch never runs — there is no NSError to catch. class Api(private val client: HttpClient) { suspend fun loadFeed(): List<Post> = client.get("/feed").body() } … - 08
Swift passed
nilwhere your Kotlin signature promised a non-null parameter — how did that get through, and where does it finally crash?HardBecause nullability in the generated header is a promise to the Swift compiler, not a runtime check — Kotlin/Native does not re-validate arguments at the boundary, so any nil Swift was not forced to unwrap walks straight into a non-null Kotlin parameter.
// ❌ Three crashes Swift cannot see coming class ProfileVm(private val repo: ProfileRepo) { private val cache = mutableMapOf<String, String>() lateinit var user: User // read before load() -> process kill fun avatarUrl(): String = user.avatar!! // NPE -> process kill, not an error … - 09
Your iOS engineers have their own repo and no JDK on their Macs — how does the shared framework reach them, and what does each option cost?
HardEither the iOS build compiles Kotlin itself or it consumes a versioned binary, and that choice — not the packaging format — is what the iOS team feels every day.
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework kotlin { val xcf = XCFramework("Shared") // one artifact, every iOS slice listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { … - 10
The app ships a widget and a share extension, and cold launch got slower after the shared module landed — static or dynamic framework?
HardisStatic = truelinks the Kotlin binary into whatever consumes it at build time, dynamic ships a separate.frameworkthat dyld loads at launch — and with several targets in one app that difference is not only launch time, it is how many Kotlin runtimes you are running.kotlin { listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { baseName = "Shared" // One consumer: link Kotlin straight into the app binary. … - 11
A vendor SDK and your shared module are both Kotlin/Native frameworks and both use kotlinx-datetime — what breaks when they land in one app?
HardEach Kotlin/Native framework is a self-contained program: it embeds the stdlib, the runtime, the garbage collector and every klib it links, so two frameworks mean two Kotlin runtimes that cannot see each other.
// umbrella/build.gradle.kts — the single Kotlin binary the iOS app links kotlin { listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { baseName = "Shared" export(projects.featureFeed) // every shared module... … - 12
A breakpoint in a .kt file never hits from Xcode and Kotlin values show as opaque objects — what has to be true to debug across the boundary?
HardLLDB can stop inside Kotlin because the Kotlin/Native compiler emits DWARF, but only for a debug-configuration framework whose recorded source paths still exist on the machine you are debugging from.
# Xcode > Build Phases > Run Script. The framework variant is what decides # whether you can debug at all — the task reads Xcode's CONFIGURATION. cd "$SRCROOT/.." ./gradlew :shared:embedAndSignAppleFrameworkForXcode # export KOTLIN_FRAMEWORK_BUILD_TYPE=release <- pinning this in a Debug # scheme is the usual reason breakpoints silently stop working … - 13
The iOS team put
[weak self]in every callback and the shared ViewModel still never deallocates — where is its lifetime actually held?HardMost "leaks" at this boundary are not ARC cycles at all: a running coroutine is a Kotlin GC root, so a ViewModel with a live collector stays alive no matter what Swift does or does not retain.
class Cancellable(private val onCancel: () -> Unit) { fun cancel() = onCancel() } // ❌ A singleton owning the scope: the screen has no way to end its own work object FeedHub { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val listeners = mutableListOf<(List<Post>) -> Unit>() … - 14
A StateFlow collected on Dispatchers.Default drives a SwiftUI @Published property and the runtime complains — what are Kotlin/Native's threading rules now?
HardKotlin/Native stopped enforcing anything years ago — freezing,
freeze(),@SharedImmutableandInvalidMutabilityExceptionare gone and objects are shared across threads as on the JVM — so every rule left at this boundary is Apple's, and placing the main-thread hop is your job.class FeedViewModel(private val repo: FeedRepo) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val _state = MutableStateFlow<FeedState>(FeedState.Loading) val state: StateFlow<FeedState> = _state … - 15
How do you shape the shared module's public API so the SwiftUI on top of it reads like Swift instead of a translated Kotlin header?
HardTreat the exported surface as a designed product with exactly one consumer, not as whatever happened to be
publicincommonMain— because that is precisely what the header generator ships.@OptIn(ExperimentalObjCRefinement::class) class FeedViewModel(private val repo: FeedRepo) { // FeedRepo is internal, never exported private val scope = MainScope() // The whole screen as one value: Swift renders this and nothing else. … - 16
Swift export takes Objective-C out of the middle of the bridge — what does that actually change, and would you ship a release on it today?
HardSwift export makes the Kotlin/Native compiler emit Swift bindings directly, so the API no longer has to be expressible in Objective-C first — which is where most of today's interop pain comes from.
// gradle.properties // kotlin.experimental.swift-export.enabled=true // shared/build.gradle.kts kotlin { iosArm64() …